nautobot 2.4.9__py3-none-any.whl → 2.4.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of nautobot might be problematic. Click here for more details.

Files changed (38) hide show
  1. nautobot/core/api/parsers.py +56 -2
  2. nautobot/core/models/__init__.py +2 -0
  3. nautobot/core/tests/test_csv.py +92 -1
  4. nautobot/core/tests/test_jinja_filters.py +59 -0
  5. nautobot/core/tests/test_views.py +73 -0
  6. nautobot/core/urls.py +2 -2
  7. nautobot/core/views/__init__.py +21 -0
  8. nautobot/dcim/models/device_component_templates.py +4 -0
  9. nautobot/dcim/models/device_components.py +12 -0
  10. nautobot/dcim/models/devices.py +6 -0
  11. nautobot/extras/context_managers.py +2 -2
  12. nautobot/extras/models/customfields.py +2 -0
  13. nautobot/extras/models/datasources.py +8 -0
  14. nautobot/extras/models/groups.py +18 -0
  15. nautobot/extras/models/jobs.py +14 -0
  16. nautobot/extras/models/metadata.py +2 -0
  17. nautobot/extras/models/models.py +4 -0
  18. nautobot/extras/models/secrets.py +7 -0
  19. nautobot/extras/secrets/__init__.py +14 -0
  20. nautobot/extras/tests/test_context_managers.py +20 -0
  21. nautobot/extras/tests/test_models.py +26 -0
  22. nautobot/ipam/models.py +32 -0
  23. nautobot/project-static/docs/development/apps/api/platform-features/secrets-providers.html +39 -38
  24. nautobot/project-static/docs/release-notes/version-1.6.html +297 -0
  25. nautobot/project-static/docs/release-notes/version-2.4.html +101 -0
  26. nautobot/project-static/docs/search/search_index.json +1 -1
  27. nautobot/project-static/docs/sitemap.xml +298 -298
  28. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  29. nautobot/project-static/docs/user-guide/administration/security/index.html +0 -1
  30. nautobot/project-static/docs/user-guide/administration/security/notices.html +113 -1
  31. nautobot/users/models.py +4 -0
  32. nautobot/virtualization/models.py +4 -0
  33. {nautobot-2.4.9.dist-info → nautobot-2.4.10.dist-info}/METADATA +2 -2
  34. {nautobot-2.4.9.dist-info → nautobot-2.4.10.dist-info}/RECORD +38 -38
  35. {nautobot-2.4.9.dist-info → nautobot-2.4.10.dist-info}/LICENSE.txt +0 -0
  36. {nautobot-2.4.9.dist-info → nautobot-2.4.10.dist-info}/NOTICE +0 -0
  37. {nautobot-2.4.9.dist-info → nautobot-2.4.10.dist-info}/WHEEL +0 -0
  38. {nautobot-2.4.9.dist-info → nautobot-2.4.10.dist-info}/entry_points.txt +0 -0
@@ -136,6 +136,56 @@ class NautobotCSVParser(BaseParser):
136
136
 
137
137
  return data_without_missing_field_lookups_values
138
138
 
139
+ def _convert_m2m_dict_to_list_of_dicts(self, data, field):
140
+ """
141
+ Converts a nested dictionary into list of flat dictionaries for M2M serializer.
142
+
143
+ Args:
144
+ data (dict): Nested dictionary with comma-separated string values.
145
+ field (str): Field name used in error messages.
146
+
147
+ Returns:
148
+ list: List of dictionaries, each containing one set of related values.
149
+
150
+ Raises:
151
+ ParseError: If the number of comma-separated values is inconsistent
152
+ across different keys.
153
+
154
+ Examples:
155
+ >>> data = {'manufacturer': {'name': 'Cisco,Cisco,Aruba'}, 'model': 'C9300,C9500,CX 6300'}
156
+ >>> field = "device_type"
157
+ >>> value = self.convert_m2m_dict_to_list_of_dicts(data, field)
158
+ >>> value
159
+ [
160
+ {'manufacturer': {'name': 'Cisco'},'model': 'C9300'},
161
+ {'manufacturer': {'name': 'Cisco'},'model': 'C9500'},
162
+ {'manufacturer': {'name': 'Aruba'},'model': 'CX 6300'}
163
+ ]
164
+ """
165
+
166
+ def flatten_dict(d, parent_key=""):
167
+ """Flatten nested dictionary with __ separated keys"""
168
+ items = []
169
+ for k, v in d.items():
170
+ new_key = f"{parent_key}__{k}" if parent_key else k
171
+ if isinstance(v, dict):
172
+ items.extend(flatten_dict(v, new_key).items())
173
+ else:
174
+ items.append((new_key, v.split(",")))
175
+ return dict(items)
176
+
177
+ flat_data = flatten_dict(data)
178
+
179
+ # Convert dictionary to list of dictionaries
180
+ values_count = {len(value) for value in flat_data.values()}
181
+ if len(values_count) > 1:
182
+ raise ParseError(f"Incorrect number of values provided for the {field} field")
183
+ values_count = values_count.pop()
184
+ return [
185
+ self._group_data_by_field_name({key: value[i] for key, value in flat_data.items()})
186
+ for i in range(values_count)
187
+ ]
188
+
139
189
  def row_elements_to_data(self, counter, row, serializer):
140
190
  """
141
191
  Parse a single row of CSV data (represented as a dict) into a dict suitable for consumption by the serializer.
@@ -171,9 +221,13 @@ class NautobotCSVParser(BaseParser):
171
221
  continue
172
222
 
173
223
  if isinstance(serializer_field, serializers.ManyRelatedField):
174
- # A list of related objects, represented as a list of composite-keys
175
224
  if value:
176
- value = value.split(",")
225
+ # A list of related objects, represented as a list of composite-keys
226
+ if isinstance(value, str):
227
+ value = value.split(",")
228
+ # A dictionary of fields identifying the objects
229
+ elif isinstance(value, dict):
230
+ value = self._convert_m2m_dict_to_list_of_dicts(value, key)
177
231
  else:
178
232
  value = []
179
233
  elif isinstance(serializer_field, serializers.RelatedField):
@@ -132,6 +132,8 @@ class BaseModel(models.Model):
132
132
  self.full_clean()
133
133
  self.save(*args, **kwargs)
134
134
 
135
+ validated_save.alters_data = True
136
+
135
137
  def natural_key(self) -> list:
136
138
  """
137
139
  Smarter default implementation of natural key construction.
@@ -7,7 +7,7 @@ from django.urls import reverse
7
7
 
8
8
  from nautobot.core.constants import CSV_NO_OBJECT, CSV_NULL_TYPE, VARBINARY_IP_FIELD_REPR_OF_CSV_NO_OBJECT
9
9
  from nautobot.dcim.api.serializers import DeviceSerializer
10
- from nautobot.dcim.models.devices import Controller, Device, DeviceType
10
+ from nautobot.dcim.models.devices import Controller, Device, DeviceType, Platform, SoftwareImageFile, SoftwareVersion
11
11
  from nautobot.dcim.models.locations import Location
12
12
  from nautobot.extras.models.roles import Role
13
13
  from nautobot.extras.models.statuses import Status
@@ -317,3 +317,94 @@ class CSVParsingRelatedTestCase(TestCase):
317
317
  tenant=self.device2.tenant,
318
318
  )
319
319
  self.assertEqual(device4.tags.count(), 0)
320
+
321
+ @override_settings(ALLOWED_HOSTS=["*"])
322
+ def test_m2m_field_import(self):
323
+ """Test CSV import of M2M field."""
324
+
325
+ platform = Platform.objects.first()
326
+ software_version_status = Status.objects.get_for_model(SoftwareVersion).first()
327
+ software_image_file_status = Status.objects.get_for_model(SoftwareImageFile).first()
328
+
329
+ software_version = SoftwareVersion.objects.create(
330
+ platform=platform, version="Test version 1.0.0", status=software_version_status
331
+ )
332
+ software_image_files = (
333
+ SoftwareImageFile.objects.create(
334
+ software_version=software_version,
335
+ image_file_name="software_image_file_qs_test_1.bin",
336
+ status=software_image_file_status,
337
+ ),
338
+ SoftwareImageFile.objects.create(
339
+ software_version=software_version,
340
+ image_file_name="software_image_file_qs_test_2.bin",
341
+ status=software_image_file_status,
342
+ default_image=True,
343
+ ),
344
+ SoftwareImageFile.objects.create(
345
+ software_version=software_version,
346
+ image_file_name="software_image_file_qs_test_3.bin",
347
+ status=software_image_file_status,
348
+ ),
349
+ )
350
+
351
+ user = UserFactory.create()
352
+ user.is_superuser = True
353
+ user.is_active = True
354
+ user.save()
355
+ self.client.force_login(user)
356
+
357
+ with self.subTest("Import M2M field using list of UUIDs"):
358
+ import_data = f"""name,device_type,location,role,status,software_image_files
359
+ TestDevice5,{self.device.device_type.pk},{self.device.location.pk},{self.device.role.pk},{self.device.status.pk},"{software_image_files[0].pk},{software_image_files[1].pk}"
360
+ """
361
+ data = {"csv_data": import_data}
362
+ url = reverse("dcim:device_import")
363
+ response = self.client.post(url, data)
364
+
365
+ self.assertEqual(response.status_code, 200)
366
+ self.assertEqual(Device.objects.count(), 3)
367
+
368
+ # Assert TestDevice5 got created with the right fields
369
+ device5 = Device.objects.get(
370
+ name="TestDevice5",
371
+ location=self.device.location,
372
+ device_type=self.device.device_type,
373
+ role=self.device.role,
374
+ status=self.device.status,
375
+ tenant=None,
376
+ )
377
+ self.assertEqual(device5.software_image_files.count(), 2)
378
+
379
+ with self.subTest("Import M2M field using multiple identifying fields"):
380
+ import_data = f"""name,device_type,location,role,status,software_image_files__software_version,software_image_files__image_file_name
381
+ TestDevice6,{self.device.device_type.pk},{self.device.location.pk},{self.device.role.pk},{self.device.status.pk},"{software_version.pk},{software_version.pk}","{software_image_files[0].image_file_name},{software_image_files[1].image_file_name}"
382
+ """
383
+ data = {"csv_data": import_data}
384
+ url = reverse("dcim:device_import")
385
+ response = self.client.post(url, data)
386
+
387
+ self.assertEqual(response.status_code, 200)
388
+ self.assertEqual(Device.objects.count(), 4)
389
+
390
+ # Assert TestDevice5 got created with the right fields
391
+ device6 = Device.objects.get(
392
+ name="TestDevice6",
393
+ location=self.device.location,
394
+ device_type=self.device.device_type,
395
+ role=self.device.role,
396
+ status=self.device.status,
397
+ tenant=None,
398
+ )
399
+ self.assertEqual(device6.software_image_files.count(), 2)
400
+
401
+ with self.subTest("Import M2M field using incorrect number of values"):
402
+ import_data = f"""name,device_type,location,role,status,software_image_files__software_version,software_image_files__image_file_name
403
+ TestDevice7,{self.device.device_type.pk},{self.device.location.pk},{self.device.role.pk},{self.device.status.pk},"{software_version.pk},{software_version.pk}","{software_image_files[0].image_file_name},{software_image_files[1].image_file_name},{software_image_files[2].image_file_name}"
404
+ """
405
+ data = {"csv_data": import_data}
406
+ url = reverse("dcim:device_import")
407
+ response = self.client.post(url, data)
408
+ self.assertEqual(response.status_code, 200)
409
+ self.assertContains(response, "Incorrect number of values provided for the software_image_files field")
410
+ self.assertEqual(Device.objects.count(), 4)
@@ -4,6 +4,8 @@ from netutils.utils import jinja2_convenience_function
4
4
 
5
5
  from nautobot.core.utils import data
6
6
  from nautobot.dcim import models as dcim_models
7
+ from nautobot.extras import models as extras_models
8
+ from nautobot.ipam import models as ipam_models
7
9
 
8
10
 
9
11
  class NautobotJinjaFilterTest(TestCase):
@@ -85,3 +87,60 @@ class NautobotJinjaFilterTest(TestCase):
85
87
  self.fail("SecurityError raised on safe Jinja template render")
86
88
  else:
87
89
  self.assertEqual(value, location.parent.name)
90
+
91
+ def test_render_blocks_various_unsafe_methods(self):
92
+ """Assert that Jinja template rendering correctly blocks various unsafe Nautobot APIs."""
93
+ device = dcim_models.Device.objects.first()
94
+ dynamic_group = extras_models.DynamicGroup.objects.first()
95
+ git_repository = extras_models.GitRepository.objects.create(
96
+ name="repo", slug="repo", remote_url="file:///", branch="main"
97
+ )
98
+ interface = dcim_models.Interface.objects.first()
99
+ interface_template = dcim_models.InterfaceTemplate.objects.first()
100
+ location = dcim_models.Location.objects.first()
101
+ module = dcim_models.Module.objects.first()
102
+ prefix = ipam_models.Prefix.objects.first()
103
+ secret = extras_models.Secret.objects.create(name="secret", provider="environment-variable")
104
+ vrf = ipam_models.VRF.objects.first()
105
+
106
+ context = {
107
+ "device": device,
108
+ "dynamic_group": dynamic_group,
109
+ "git_repository": git_repository,
110
+ "interface": interface,
111
+ "interface_template": interface_template,
112
+ "location": location,
113
+ "module": module,
114
+ "prefix": prefix,
115
+ "secret": secret,
116
+ "vrf": vrf,
117
+ "JobResult": extras_models.JobResult,
118
+ "ScheduledJob": extras_models.ScheduledJob,
119
+ }
120
+
121
+ for call in [
122
+ "device.create_components()",
123
+ "dynamic_group.add_members([])",
124
+ "dynamic_group.remove_members([])",
125
+ "git_repository.sync(None)",
126
+ "git_repository.clone_to_directory()",
127
+ "git_repository.cleanup_cloned_directory('/tmp/')",
128
+ "interface.render_name_template()",
129
+ "interface.add_ip_addresses([])",
130
+ "interface_template.instantiate(device)",
131
+ "interface_template.instantiate_model(interface_template, device)",
132
+ "location.validated_save()",
133
+ "module.create_components()",
134
+ "module.render_component_names()",
135
+ "prefix.reparent_ips()",
136
+ "prefix.reparent_subnets()",
137
+ "secret.get_value()",
138
+ "vrf.add_device(device)",
139
+ "vrf.add_prefix(prefix)",
140
+ "JobResult.enqueue_job(None, None)",
141
+ "JobResult.log('hello world')",
142
+ "ScheduledJob.create_schedule(None, None)",
143
+ ]:
144
+ with self.subTest(call=call):
145
+ with self.assertRaises(SecurityError):
146
+ data.render_jinja2(template_code="{{ " + call + " }}", context=context)
@@ -1,5 +1,7 @@
1
1
  import json
2
+ import os
2
3
  import re
4
+ import tempfile
3
5
  from unittest import mock, skipIf
4
6
  import urllib.parse
5
7
 
@@ -185,6 +187,77 @@ class HomeViewTestCase(TestCase):
185
187
  self.assertNotIn("Welcome to Nautobot!", response.content.decode(response.charset))
186
188
 
187
189
 
190
+ class MediaViewTestCase(TestCase):
191
+ def test_media_unauthenticated(self):
192
+ """
193
+ Test that unauthenticated users are redirected to login when accessing media files whether they exist or not.
194
+ """
195
+ with tempfile.TemporaryDirectory() as temp_dir:
196
+ with override_settings(
197
+ MEDIA_ROOT=temp_dir,
198
+ BRANDING_FILEPATHS={"logo": os.path.join("branding", "logo.txt")},
199
+ ):
200
+ file_path = os.path.join(temp_dir, "foo.txt")
201
+ url = reverse("media", kwargs={"path": "foo.txt"})
202
+ self.client.logout()
203
+
204
+ # Unauthenticated request to nonexistent media file should redirect to login page
205
+ response = self.client.get(url)
206
+ self.assertRedirects(
207
+ response, expected_url=f"{reverse('login')}?next={url}", status_code=302, target_status_code=200
208
+ )
209
+
210
+ # Unauthenticated request to existent media file should redirect to login page as well
211
+ with open(file_path, "w") as f:
212
+ f.write("Hello, world!")
213
+ response = self.client.get(url)
214
+ self.assertRedirects(
215
+ response, expected_url=f"{reverse('login')}?next={url}", status_code=302, target_status_code=200
216
+ )
217
+
218
+ def test_branding_media(self):
219
+ """
220
+ Test that users can access branding files listed in `settings.BRANDING_FILEPATHS` regardless of authentication.
221
+ """
222
+ with tempfile.TemporaryDirectory() as temp_dir:
223
+ with override_settings(
224
+ MEDIA_ROOT=temp_dir,
225
+ BRANDING_FILEPATHS={"logo": os.path.join("branding", "logo.txt")},
226
+ ):
227
+ os.makedirs(os.path.join(temp_dir, "branding"))
228
+ file_path = os.path.join(temp_dir, "branding", "logo.txt")
229
+ with open(file_path, "w") as f:
230
+ f.write("Hello, world!")
231
+
232
+ url = reverse("media", kwargs={"path": "branding/logo.txt"})
233
+
234
+ # Authenticated request succeeds
235
+ response = self.client.get(url)
236
+ self.assertHttpStatus(response, 200)
237
+ self.assertIn("Hello, world!", b"".join(response).decode(response.charset))
238
+
239
+ # Unauthenticated request also succeeds
240
+ self.client.logout()
241
+ response = self.client.get(url)
242
+ self.assertHttpStatus(response, 200)
243
+ self.assertIn("Hello, world!", b"".join(response).decode(response.charset))
244
+
245
+ def test_media_authenticated(self):
246
+ """
247
+ Test that authenticated users can access regular media files stored in the `MEDIA_ROOT`.
248
+ """
249
+ with tempfile.TemporaryDirectory() as temp_dir:
250
+ with override_settings(MEDIA_ROOT=temp_dir):
251
+ file_path = os.path.join(temp_dir, "foo.txt")
252
+ with open(file_path, "w") as f:
253
+ f.write("Hello, world!")
254
+
255
+ url = reverse("media", kwargs={"path": "foo.txt"})
256
+ response = self.client.get(url)
257
+ self.assertHttpStatus(response, 200)
258
+ self.assertIn("Hello, world!", b"".join(response).decode(response.charset))
259
+
260
+
188
261
  @override_settings(BRANDING_TITLE="Nautobot")
189
262
  class SearchFieldsTestCase(TestCase):
190
263
  def test_search_bar_redirect_to_login(self):
nautobot/core/urls.py CHANGED
@@ -2,13 +2,13 @@ from django.conf import settings
2
2
  from django.http import HttpResponse, HttpResponseNotFound
3
3
  from django.urls import include, path
4
4
  from django.views.generic import TemplateView
5
- from django.views.static import serve
6
5
 
7
6
  from nautobot.core.views import (
8
7
  AboutView,
9
8
  CustomGraphQLView,
10
9
  get_file_with_authorization,
11
10
  HomeView,
11
+ MediaView,
12
12
  NautobotMetricsView,
13
13
  NautobotMetricsViewAuth,
14
14
  RenderJinjaView,
@@ -51,7 +51,7 @@ urlpatterns = [
51
51
  # GraphQL
52
52
  path("graphql/", CustomGraphQLView.as_view(graphiql=True), name="graphql"),
53
53
  # Serving static media in Django (TODO: should be DEBUG mode only - "This view is NOT hardened for production use")
54
- path("media/<path:path>", serve, {"document_root": settings.MEDIA_ROOT}),
54
+ path("media/<path:path>", MediaView.as_view(), name="media"),
55
55
  # Admin
56
56
  path("admin/", admin_site.urls),
57
57
  # Errors
@@ -3,6 +3,7 @@ import datetime
3
3
  import logging
4
4
  import os
5
5
  import platform
6
+ import posixpath
6
7
  import re
7
8
  import sys
8
9
  import time
@@ -24,6 +25,7 @@ from django.views.csrf import csrf_failure as _csrf_failure
24
25
  from django.views.decorators.csrf import requires_csrf_token
25
26
  from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
26
27
  from django.views.generic import TemplateView, View
28
+ from django.views.static import serve
27
29
  from graphene_django.views import GraphQLView
28
30
  from packaging import version
29
31
  from prometheus_client import (
@@ -133,6 +135,25 @@ class HomeView(AccessMixin, TemplateView):
133
135
  return self.render_to_response(context)
134
136
 
135
137
 
138
+ class MediaView(AccessMixin, View):
139
+ """
140
+ Serves media files while enforcing login restrictions.
141
+
142
+ This view wraps Django's `serve()` function to ensure that access to media files (with the exception of
143
+ branding files defined in `settings.BRANDING_FILEPATHS`) is restricted to authenticated users.
144
+ """
145
+
146
+ def get(self, request, path):
147
+ if request.user.is_authenticated:
148
+ return serve(request, path, document_root=settings.MEDIA_ROOT)
149
+
150
+ # Unauthenticated users can access BRANDING_FILEPATHS only
151
+ if posixpath.normpath(path).lstrip("/") in settings.BRANDING_FILEPATHS.values():
152
+ return serve(request, path, document_root=settings.MEDIA_ROOT)
153
+
154
+ return self.handle_no_permission()
155
+
156
+
136
157
  class WorkerStatusView(UserPassesTestMixin, TemplateView):
137
158
  template_name = "utilities/worker_status.html"
138
159
 
@@ -82,6 +82,8 @@ class ComponentTemplateModel(
82
82
  """
83
83
  raise NotImplementedError()
84
84
 
85
+ instantiate.alters_data = True
86
+
85
87
  def to_objectchange(self, action, **kwargs):
86
88
  """
87
89
  Return a new ObjectChange with the `related_object` pinned to the `device_type` by default.
@@ -122,6 +124,8 @@ class ComponentTemplateModel(
122
124
  **kwargs,
123
125
  )
124
126
 
127
+ instantiate_model.alters_data = True
128
+
125
129
 
126
130
  class ModularComponentTemplateModel(ComponentTemplateModel):
127
131
  """Component Template that supports assignment to a DeviceType or a ModuleType."""
@@ -182,6 +182,8 @@ class ModularComponentModel(ComponentModel):
182
182
  if save:
183
183
  self.save(update_fields=["_name", "name"])
184
184
 
185
+ render_name_template.alters_data = True
186
+
185
187
  def to_objectchange(self, action, **kwargs):
186
188
  """
187
189
  Return a new ObjectChange with the `related_object` pinned to the parent `device` or `module`.
@@ -820,6 +822,8 @@ class Interface(ModularComponentModel, CableTermination, PathEndpoint, BaseInter
820
822
  instance.validated_save()
821
823
  return len(ip_addresses)
822
824
 
825
+ add_ip_addresses.alters_data = True
826
+
823
827
  def remove_ip_addresses(self, ip_addresses):
824
828
  """Remove one or more IPAddress instances from this interface's `ip_addresses` many-to-many relationship.
825
829
 
@@ -839,6 +843,8 @@ class Interface(ModularComponentModel, CableTermination, PathEndpoint, BaseInter
839
843
  count += deleted_count
840
844
  return count
841
845
 
846
+ remove_ip_addresses.alters_data = True
847
+
842
848
  @property
843
849
  def is_connectable(self):
844
850
  return self.type not in NONCONNECTABLE_IFACE_TYPES
@@ -939,6 +945,8 @@ class InterfaceRedundancyGroup(PrimaryModel): # pylint: disable=too-many-ancest
939
945
  )
940
946
  return instance.validated_save()
941
947
 
948
+ add_interface.alters_data = True
949
+
942
950
  def remove_interface(self, interface):
943
951
  """
944
952
  Remove an interface.
@@ -952,6 +960,8 @@ class InterfaceRedundancyGroup(PrimaryModel): # pylint: disable=too-many-ancest
952
960
  )
953
961
  return instance.delete()
954
962
 
963
+ remove_interface.alters_data = True
964
+
955
965
 
956
966
  @extras_features("graphql")
957
967
  class InterfaceRedundancyGroupAssociation(BaseModel, ChangeLoggedModel):
@@ -1287,3 +1297,5 @@ class ModuleBay(PrimaryModel):
1287
1297
 
1288
1298
  if not self.position:
1289
1299
  self.position = self.name
1300
+
1301
+ clean.alters_data = True
@@ -897,6 +897,8 @@ class Device(PrimaryModel, ConfigContextModel):
897
897
  model.objects.bulk_create([x.instantiate(device=self) for x in templates])
898
898
  return instantiated_components
899
899
 
900
+ create_components.alters_data = True
901
+
900
902
  @property
901
903
  def display(self):
902
904
  if self.name:
@@ -1858,6 +1860,8 @@ class Module(PrimaryModel):
1858
1860
  model.objects.bulk_create([x.instantiate(device=None, module=self) for x in templates])
1859
1861
  return instantiated_components
1860
1862
 
1863
+ create_components.alters_data = True
1864
+
1861
1865
  def render_component_names(self):
1862
1866
  """
1863
1867
  Replace the {module}, {module.parent}, {module.parent.parent}, etc. template variables in descendant
@@ -1882,6 +1886,8 @@ class Module(PrimaryModel):
1882
1886
  for child in self.get_children():
1883
1887
  child.render_component_names()
1884
1888
 
1889
+ render_component_names.alters_data = True
1890
+
1885
1891
  def get_cables(self, pk_list=False):
1886
1892
  """
1887
1893
  Return a QuerySet or PK list matching all Cables connected to any component of this Module.
@@ -254,8 +254,8 @@ def web_request_context(
254
254
  # TODO: get_snapshots() currently requires a DB query per object change processed.
255
255
  # We need to develop a more efficient approach: https://github.com/nautobot/nautobot/issues/6303
256
256
  snapshots = oc.get_snapshots(
257
- pre_object_data.get(str(oc.changed_object_id), None),
258
- pre_object_data_v2.get(str(oc.changed_object_id), None),
257
+ pre_object_data.get(str(oc.changed_object_id), None) if pre_object_data else None,
258
+ pre_object_data_v2.get(str(oc.changed_object_id), None) if pre_object_data_v2 else None,
259
259
  )
260
260
  webhook_queryset = enqueue_webhooks(oc, snapshots=snapshots, webhook_queryset=webhook_queryset)
261
261
 
@@ -269,6 +269,8 @@ class CustomFieldModel(models.Model):
269
269
  elif cf.required:
270
270
  raise ValidationError(f"Missing required custom field '{cf.key}'.")
271
271
 
272
+ clean.alters_data = True
273
+
272
274
  # Computed Field Methods
273
275
  def has_computed_fields(self, advanced_ui=None):
274
276
  """
@@ -180,6 +180,8 @@ class GitRepository(PrimaryModel):
180
180
  return enqueue_git_repository_diff_origin_and_local(self, user)
181
181
  return enqueue_pull_git_repository_and_refresh_data(self, user)
182
182
 
183
+ sync.alters_data = True
184
+
183
185
  @contextmanager
184
186
  def clone_to_directory_context(self, path=None, branch=None, head=None, depth=0):
185
187
  """
@@ -207,6 +209,8 @@ class GitRepository(PrimaryModel):
207
209
  if path_name:
208
210
  self.cleanup_cloned_directory(path_name)
209
211
 
212
+ clone_to_directory_context.alters_data = True
213
+
210
214
  def clone_to_directory(self, path=None, branch=None, head=None, depth=0):
211
215
  """
212
216
  Perform a (shallow or full) clone of the Git repository in a temporary directory.
@@ -246,6 +250,8 @@ class GitRepository(PrimaryModel):
246
250
  logger.info(f"Cloned repository {self.name} to {path_name}")
247
251
  return path_name
248
252
 
253
+ clone_to_directory.alters_data = True
254
+
249
255
  def cleanup_cloned_directory(self, path):
250
256
  """
251
257
  Cleanup the cloned directory.
@@ -259,3 +265,5 @@ class GitRepository(PrimaryModel):
259
265
  except OSError as os_error:
260
266
  # log error if the cleanup fails
261
267
  logger.error(f"Failed to cleanup temporary directory at {path}: {os_error}")
268
+
269
+ cleanup_cloned_directory.alters_data = True
@@ -338,6 +338,8 @@ class DynamicGroup(PrimaryModel):
338
338
 
339
339
  return self.members
340
340
 
341
+ _set_members.alters_data = True
342
+
341
343
  def add_members(self, objects_to_add):
342
344
  """Add the given list or QuerySet of objects to this staticly defined group."""
343
345
  if self.group_type != DynamicGroupTypeChoices.TYPE_STATIC:
@@ -354,6 +356,8 @@ class DynamicGroup(PrimaryModel):
354
356
  objects_to_add = [obj for obj in objects_to_add if obj not in existing_members]
355
357
  return self._add_members(objects_to_add)
356
358
 
359
+ add_members.alters_data = True
360
+
357
361
  def _add_members(self, objects_to_add):
358
362
  """
359
363
  Internal API for adding the given list or QuerySet of objects to the cached/static members of this group.
@@ -377,6 +381,8 @@ class DynamicGroup(PrimaryModel):
377
381
  ]
378
382
  StaticGroupAssociation.all_objects.bulk_create(sgas, batch_size=1000)
379
383
 
384
+ _add_members.alters_data = True
385
+
380
386
  def remove_members(self, objects_to_remove):
381
387
  """Remove the given list or QuerySet of objects from this staticly defined group."""
382
388
  if self.group_type != DynamicGroupTypeChoices.TYPE_STATIC:
@@ -390,6 +396,8 @@ class DynamicGroup(PrimaryModel):
390
396
  raise TypeError(f"{obj} is not a {self.model._meta.label_lower}")
391
397
  return self._remove_members(objects_to_remove)
392
398
 
399
+ remove_members.alters_data = True
400
+
393
401
  def _remove_members(self, objects_to_remove):
394
402
  """Internal API for removing the given list or QuerySet from the cached/static members of this Group."""
395
403
  from nautobot.extras.signals import _handle_deleted_object # avoid circular import
@@ -418,6 +426,8 @@ class DynamicGroup(PrimaryModel):
418
426
  logger.debug("Re-connecting the _handle_deleted_object signal")
419
427
  pre_delete.connect(_handle_deleted_object)
420
428
 
429
+ _remove_members.alters_data = True
430
+
421
431
  @property
422
432
  @method_deprecated("Members are now cached in the database via StaticGroupAssociations rather than in Redis.")
423
433
  def members_cache_key(self):
@@ -451,6 +461,8 @@ class DynamicGroup(PrimaryModel):
451
461
 
452
462
  return members
453
463
 
464
+ update_cached_members.alters_data = True
465
+
454
466
  def has_member(self, obj, use_cache=False):
455
467
  """
456
468
  Return True if the given object is a member of this group.
@@ -560,6 +572,8 @@ class DynamicGroup(PrimaryModel):
560
572
 
561
573
  self.filter = new_filter
562
574
 
575
+ set_filter.alters_data = True
576
+
563
577
  def get_initial(self):
564
578
  """
565
579
  Return a form-friendly version of `self.filter` for initial form data.
@@ -815,6 +829,8 @@ class DynamicGroup(PrimaryModel):
815
829
  instance = self.children.through(parent_group=self, group=child, operator=operator, weight=weight)
816
830
  return instance.validated_save()
817
831
 
832
+ add_child.alters_data = True
833
+
818
834
  # TODO: unused in core
819
835
  def remove_child(self, child):
820
836
  """
@@ -829,6 +845,8 @@ class DynamicGroup(PrimaryModel):
829
845
  instance = self.children.through.objects.get(parent_group=self, group=child)
830
846
  return instance.delete()
831
847
 
848
+ remove_child.alters_data = True
849
+
832
850
  def get_descendants(self, group=None):
833
851
  """
834
852
  Recursively return a list of the children of all child groups.