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.

@@ -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.\nIf you do not want the models to be searchable, please include them in the GLOBAL_SEARCH_EXCLUDE_LIST constant in nautobot.core.constants.'
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):
@@ -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 transaction.atomic():
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.delete()
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)
@@ -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.delete()[1][model._meta.label]
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 transaction.atomic():
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
- os.environ["DJANGO_SETTINGS_MODULE"] = "nautobot_config"
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
- valid_locations = [None, self.cleaned_data[parent_field].location]
198
- invalid_vlans = [str(v) for v in tagged_vlans if v.location not in valid_locations]
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 or node.querySelector(querySelector) === 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 %}
@@ -20,7 +20,7 @@
20
20
  </tr>
21
21
  <tr>
22
22
  <td>Total Devices</td>
23
- <td>{{ total_devices }}</td>
23
+ <td><a href="{% url 'dcim:device_list' %}?device_family={{ object.name }}">{{ total_devices }}</a></td>
24
24
  </tr>
25
25
  </table>
26
26
  </div>
@@ -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()
@@ -103,7 +103,9 @@ def _handle_changed_object(sender, instance, raw=False, **kwargs):
103
103
  if raw:
104
104
  return
105
105
 
106
- if change_context_state.get() is None:
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 = change_context_state.get().get_user(instance)
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
- related_changes = ObjectChange.objects.filter(
130
- changed_object_type=ContentType.objects.get_for_model(instance),
131
- changed_object_id=instance.pk,
132
- user=user,
133
- request_id=change_context_state.get().change_id,
134
- )
135
- objectchange = instance.to_objectchange(action)
136
- if related_changes.exists():
137
- most_recent_change = related_changes.order_by("-time").first()
138
- if most_recent_change.action == ObjectChangeActionChoices.ACTION_DELETE:
139
- most_recent_change.action = ObjectChangeActionChoices.ACTION_UPDATE
140
- most_recent_change.object_data = objectchange.object_data
141
- most_recent_change.object_data_v2 = objectchange.object_data_v2
142
- most_recent_change.save()
143
- objectchange = most_recent_change
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
- objectchange.user = user
146
- objectchange.request_id = change_context_state.get().change_id
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
- objectchange.save()
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
- if change_context_state.get() is None:
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 = change_context_state.get().get_user(instance)
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
- related_changes = ObjectChange.objects.filter(
199
- changed_object_type=ContentType.objects.get_for_model(instance),
200
- changed_object_id=instance.pk,
201
- user=user,
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
- objectchange.user = user
218
- objectchange.request_id = change_context_state.get().change_id
219
- objectchange.change_context = change_context_state.get().context
220
- objectchange.change_context_detail = change_context_state.get().context_detail[
221
- :CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL
222
- ]
223
- objectchange.save()
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 web_request_context
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:location",
774
+ viewname="dcim:location_list",
775
775
  url_params={"vlans": "pk"},
776
776
  verbose_name="Locations",
777
777
  )