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.
- nautobot/core/api/parsers.py +56 -2
- nautobot/core/models/__init__.py +2 -0
- nautobot/core/tests/test_csv.py +92 -1
- nautobot/core/tests/test_jinja_filters.py +59 -0
- nautobot/core/tests/test_views.py +73 -0
- nautobot/core/urls.py +2 -2
- nautobot/core/views/__init__.py +21 -0
- nautobot/dcim/models/device_component_templates.py +4 -0
- nautobot/dcim/models/device_components.py +12 -0
- nautobot/dcim/models/devices.py +6 -0
- nautobot/extras/context_managers.py +2 -2
- nautobot/extras/models/customfields.py +2 -0
- nautobot/extras/models/datasources.py +8 -0
- nautobot/extras/models/groups.py +18 -0
- nautobot/extras/models/jobs.py +14 -0
- nautobot/extras/models/metadata.py +2 -0
- nautobot/extras/models/models.py +4 -0
- nautobot/extras/models/secrets.py +7 -0
- nautobot/extras/secrets/__init__.py +14 -0
- nautobot/extras/tests/test_context_managers.py +20 -0
- nautobot/extras/tests/test_models.py +26 -0
- nautobot/ipam/models.py +32 -0
- nautobot/project-static/docs/development/apps/api/platform-features/secrets-providers.html +39 -38
- nautobot/project-static/docs/release-notes/version-1.6.html +297 -0
- nautobot/project-static/docs/release-notes/version-2.4.html +101 -0
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +298 -298
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/security/index.html +0 -1
- nautobot/project-static/docs/user-guide/administration/security/notices.html +113 -1
- nautobot/users/models.py +4 -0
- nautobot/virtualization/models.py +4 -0
- {nautobot-2.4.9.dist-info → nautobot-2.4.10.dist-info}/METADATA +2 -2
- {nautobot-2.4.9.dist-info → nautobot-2.4.10.dist-info}/RECORD +38 -38
- {nautobot-2.4.9.dist-info → nautobot-2.4.10.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.9.dist-info → nautobot-2.4.10.dist-info}/NOTICE +0 -0
- {nautobot-2.4.9.dist-info → nautobot-2.4.10.dist-info}/WHEEL +0 -0
- {nautobot-2.4.9.dist-info → nautobot-2.4.10.dist-info}/entry_points.txt +0 -0
nautobot/core/api/parsers.py
CHANGED
|
@@ -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
|
-
|
|
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):
|
nautobot/core/models/__init__.py
CHANGED
nautobot/core/tests/test_csv.py
CHANGED
|
@@ -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>",
|
|
54
|
+
path("media/<path:path>", MediaView.as_view(), name="media"),
|
|
55
55
|
# Admin
|
|
56
56
|
path("admin/", admin_site.urls),
|
|
57
57
|
# Errors
|
nautobot/core/views/__init__.py
CHANGED
|
@@ -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
|
nautobot/dcim/models/devices.py
CHANGED
|
@@ -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
|
nautobot/extras/models/groups.py
CHANGED
|
@@ -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.
|