nautobot 2.2.1__py3-none-any.whl → 2.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nautobot/apps/jobs.py +2 -0
- nautobot/core/api/utils.py +12 -9
- nautobot/core/apps/__init__.py +2 -2
- nautobot/core/celery/__init__.py +79 -68
- nautobot/core/celery/backends.py +9 -1
- nautobot/core/celery/control.py +4 -7
- nautobot/core/celery/schedulers.py +4 -2
- nautobot/core/celery/task.py +78 -5
- nautobot/core/graphql/schema.py +2 -1
- nautobot/core/jobs/__init__.py +2 -1
- nautobot/core/templates/generic/object_list.html +3 -3
- nautobot/core/templatetags/helpers.py +66 -9
- nautobot/core/testing/__init__.py +6 -1
- nautobot/core/testing/api.py +12 -13
- nautobot/core/testing/mixins.py +2 -2
- nautobot/core/testing/views.py +50 -51
- nautobot/core/tests/test_api.py +23 -2
- nautobot/core/tests/test_templatetags_helpers.py +32 -0
- nautobot/core/tests/test_views.py +21 -1
- nautobot/core/tests/test_views_utils.py +22 -1
- nautobot/core/utils/module_loading.py +89 -0
- nautobot/core/views/generic.py +4 -4
- nautobot/core/views/mixins.py +4 -3
- nautobot/core/views/utils.py +3 -2
- nautobot/core/wsgi.py +9 -2
- nautobot/dcim/choices.py +14 -0
- nautobot/dcim/forms.py +59 -4
- nautobot/dcim/models/device_components.py +9 -5
- nautobot/dcim/templates/dcim/device/lldp_neighbors.html +2 -2
- nautobot/dcim/templates/dcim/devicefamily_retrieve.html +1 -1
- nautobot/dcim/templates/dcim/location.html +32 -13
- nautobot/dcim/templates/dcim/location_migrate_data_to_contact.html +102 -0
- nautobot/dcim/tests/test_forms.py +49 -2
- nautobot/dcim/tests/test_views.py +137 -0
- nautobot/dcim/urls.py +5 -0
- nautobot/dcim/views.py +149 -1
- nautobot/extras/api/views.py +21 -10
- nautobot/extras/constants.py +3 -3
- nautobot/extras/context_managers.py +56 -0
- nautobot/extras/datasources/git.py +47 -58
- nautobot/extras/forms/forms.py +3 -1
- nautobot/extras/jobs.py +79 -146
- nautobot/extras/models/datasources.py +0 -2
- nautobot/extras/models/jobs.py +36 -18
- nautobot/extras/plugins/__init__.py +1 -20
- nautobot/extras/signals.py +88 -57
- nautobot/extras/test_jobs/__init__.py +8 -0
- nautobot/extras/test_jobs/dry_run.py +3 -2
- nautobot/extras/test_jobs/fail.py +43 -0
- nautobot/extras/test_jobs/ipaddress_vars.py +40 -1
- nautobot/extras/test_jobs/jobs_module/__init__.py +5 -0
- nautobot/extras/test_jobs/jobs_module/jobs_submodule/__init__.py +1 -0
- nautobot/extras/test_jobs/jobs_module/jobs_submodule/jobs.py +6 -0
- nautobot/extras/test_jobs/pass.py +40 -0
- nautobot/extras/test_jobs/relative_import.py +11 -0
- nautobot/extras/tests/test_api.py +3 -0
- nautobot/extras/tests/test_context_managers.py +98 -1
- nautobot/extras/tests/test_datasources.py +125 -118
- nautobot/extras/tests/test_job_variables.py +57 -15
- nautobot/extras/tests/test_jobs.py +135 -1
- nautobot/extras/tests/test_models.py +26 -19
- nautobot/extras/tests/test_plugins.py +1 -3
- nautobot/extras/tests/test_views.py +2 -4
- nautobot/extras/utils.py +37 -0
- nautobot/extras/views.py +47 -95
- nautobot/ipam/api/views.py +8 -1
- nautobot/ipam/graphql/types.py +11 -0
- nautobot/ipam/mixins.py +32 -0
- nautobot/ipam/models.py +2 -1
- nautobot/ipam/querysets.py +6 -1
- nautobot/ipam/tables.py +1 -1
- nautobot/ipam/tests/test_models.py +82 -0
- nautobot/project-static/docs/assets/extra.css +4 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/api.html +1 -1
- nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +180 -211
- nautobot/project-static/docs/development/apps/api/platform-features/jobs.html +1 -1
- nautobot/project-static/docs/development/core/application-registry.html +126 -84
- nautobot/project-static/docs/development/core/model-checklist.html +49 -1
- nautobot/project-static/docs/development/core/model-features.html +1 -1
- nautobot/project-static/docs/development/jobs/index.html +334 -58
- nautobot/project-static/docs/development/jobs/migration/from-v1.html +1 -1
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/release-notes/version-1.6.html +504 -201
- nautobot/project-static/docs/release-notes/version-2.2.html +392 -43
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +254 -254
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +7 -4
- nautobot/project-static/docs/user-guide/core-data-model/ipam/vlan.html +111 -0
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/index.html +15 -28
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/models.html +4 -4
- nautobot/project-static/js/forms.js +18 -11
- {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/METADATA +3 -3
- {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/RECORD +98 -92
- nautobot/extras/test_jobs/job_variables.py +0 -93
- {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/NOTICE +0 -0
- {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/WHEEL +0 -0
- {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import datetime
|
|
2
2
|
from io import StringIO
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
import re
|
|
6
7
|
import tempfile
|
|
@@ -34,7 +35,7 @@ from nautobot.extras.choices import (
|
|
|
34
35
|
ObjectChangeEventContextChoices,
|
|
35
36
|
)
|
|
36
37
|
from nautobot.extras.context_managers import change_logging, JobHookChangeContext, web_request_context
|
|
37
|
-
from nautobot.extras.jobs import get_job
|
|
38
|
+
from nautobot.extras.jobs import get_job, get_jobs
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
class JobTest(TestCase):
|
|
@@ -174,6 +175,111 @@ class JobTest(TestCase):
|
|
|
174
175
|
self.assertFalse(job_class.supports_dryrun)
|
|
175
176
|
self.assertFalse(job_model.supports_dryrun)
|
|
176
177
|
|
|
178
|
+
def test_submodule_in_jobs_root(self):
|
|
179
|
+
"""
|
|
180
|
+
Test that a subdirectory/submodule in JOBS_ROOT can contain Jobs.
|
|
181
|
+
"""
|
|
182
|
+
job_class, job_model = get_job_class_and_model("jobs_module.jobs_submodule.jobs", "ChildJob")
|
|
183
|
+
self.assertIsNotNone(job_class)
|
|
184
|
+
self.assertIsNotNone(job_model)
|
|
185
|
+
|
|
186
|
+
def test_relative_import_among_files_in_jobs_root(self):
|
|
187
|
+
"""
|
|
188
|
+
Test that a module in JOBS_ROOT can import from other modules in JOBS_ROOT.
|
|
189
|
+
"""
|
|
190
|
+
job_class, job_model = get_job_class_and_model("relative_import", "TestReallyPass")
|
|
191
|
+
self.assertIsNotNone(job_class)
|
|
192
|
+
self.assertIsNotNone(job_model)
|
|
193
|
+
|
|
194
|
+
def test_get_jobs_from_jobs_root(self):
|
|
195
|
+
"""
|
|
196
|
+
Test that get_jobs() correctly loads jobs from JOBS_ROOT as its contents change.
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
200
|
+
with override_settings(JOBS_ROOT=temp_dir):
|
|
201
|
+
# Create a new Job and make sure it's discovered correctly
|
|
202
|
+
with open(os.path.join(temp_dir, "my_jobs.py"), "w") as fd:
|
|
203
|
+
fd.write("""\
|
|
204
|
+
from nautobot.apps.jobs import Job, register_jobs
|
|
205
|
+
class MyJob(Job):
|
|
206
|
+
def run(self):
|
|
207
|
+
pass
|
|
208
|
+
register_jobs(MyJob)
|
|
209
|
+
""")
|
|
210
|
+
jobs_data = get_jobs(reload=True)
|
|
211
|
+
self.assertIn("my_jobs.MyJob", jobs_data.keys())
|
|
212
|
+
self.assertIsNotNone(get_job("my_jobs.MyJob"))
|
|
213
|
+
# Also make sure some representative previous JOBS_ROOT jobs aren't still around:
|
|
214
|
+
self.assertNotIn("dry_run.TestDryRun", jobs_data.keys())
|
|
215
|
+
self.assertNotIn("pass.TestPass", jobs_data.keys())
|
|
216
|
+
|
|
217
|
+
# Create a second Job in the same module
|
|
218
|
+
with open(os.path.join(temp_dir, "my_jobs.py"), "a") as fd:
|
|
219
|
+
fd.write("""
|
|
220
|
+
class MyOtherJob(MyJob):
|
|
221
|
+
pass
|
|
222
|
+
register_jobs(MyOtherJob)
|
|
223
|
+
""")
|
|
224
|
+
jobs_data = get_jobs(reload=True)
|
|
225
|
+
self.assertIn("my_jobs.MyJob", jobs_data.keys())
|
|
226
|
+
self.assertIsNotNone(get_job("my_jobs.MyJob"))
|
|
227
|
+
self.assertIn("my_jobs.MyOtherJob", jobs_data.keys())
|
|
228
|
+
self.assertIsNotNone(get_job("my_jobs.MyOtherJob"))
|
|
229
|
+
|
|
230
|
+
# Create a third Job in another module
|
|
231
|
+
with open(os.path.join(temp_dir, "their_jobs.py"), "w") as fd:
|
|
232
|
+
fd.write("""
|
|
233
|
+
from nautobot.apps.jobs import Job, register_jobs
|
|
234
|
+
|
|
235
|
+
class MyJob(Job):
|
|
236
|
+
def run(self):
|
|
237
|
+
pass
|
|
238
|
+
register_jobs(MyJob)
|
|
239
|
+
""")
|
|
240
|
+
jobs_data = get_jobs(reload=True)
|
|
241
|
+
self.assertIn("my_jobs.MyJob", jobs_data.keys())
|
|
242
|
+
self.assertIsNotNone(get_job("my_jobs.MyJob"))
|
|
243
|
+
self.assertIn("my_jobs.MyOtherJob", jobs_data.keys())
|
|
244
|
+
self.assertIsNotNone(get_job("my_jobs.MyOtherJob"))
|
|
245
|
+
self.assertIn("their_jobs.MyJob", jobs_data.keys())
|
|
246
|
+
self.assertIsNotNone(get_job("their_jobs.MyJob"))
|
|
247
|
+
self.assertNotEqual(get_job("my_jobs.MyJob"), get_job("their_jobs.MyJob"))
|
|
248
|
+
|
|
249
|
+
# Delete a module
|
|
250
|
+
os.remove(os.path.join(temp_dir, "their_jobs.py"))
|
|
251
|
+
jobs_data = get_jobs(reload=True)
|
|
252
|
+
self.assertIn("my_jobs.MyJob", jobs_data.keys())
|
|
253
|
+
self.assertIsNotNone(get_job("my_jobs.MyJob"))
|
|
254
|
+
self.assertIn("my_jobs.MyOtherJob", jobs_data.keys())
|
|
255
|
+
self.assertIsNotNone(get_job("my_jobs.MyOtherJob"))
|
|
256
|
+
self.assertNotIn("their_jobs", jobs_data.keys())
|
|
257
|
+
self.assertIsNone(get_job("their_jobs.MyJob"))
|
|
258
|
+
|
|
259
|
+
# Create a module with an inauspicious name
|
|
260
|
+
with open(os.path.join(temp_dir, "traceback.py"), "w") as fd:
|
|
261
|
+
fd.write("""
|
|
262
|
+
from nautobot.apps.jobs import Job, register_jobs
|
|
263
|
+
|
|
264
|
+
class BadJob(Job):
|
|
265
|
+
def run(self):
|
|
266
|
+
raise RuntimeError("You ran a bad job!")
|
|
267
|
+
register_jobs(BadJob)
|
|
268
|
+
""")
|
|
269
|
+
jobs_data = get_jobs(reload=True)
|
|
270
|
+
self.assertIn("my_jobs.MyJob", jobs_data.keys())
|
|
271
|
+
self.assertIsNotNone(get_job("my_jobs.MyJob"))
|
|
272
|
+
self.assertIn("my_jobs.MyOtherJob", jobs_data.keys())
|
|
273
|
+
self.assertIsNotNone(get_job("my_jobs.MyOtherJob"))
|
|
274
|
+
# Since `traceback` conflicts with a system module, it should not get loaded
|
|
275
|
+
self.assertNotIn("traceback.BadJob", jobs_data.keys())
|
|
276
|
+
self.assertIsNone(get_job("traceback.BadJob"))
|
|
277
|
+
|
|
278
|
+
# TODO: testing with subdirectories/submodules under JOBS_ROOT...
|
|
279
|
+
finally:
|
|
280
|
+
# Clean up back to normal behavior
|
|
281
|
+
get_jobs(reload=True)
|
|
282
|
+
|
|
177
283
|
|
|
178
284
|
class JobTransactionTest(TransactionTestCase):
|
|
179
285
|
"""
|
|
@@ -216,6 +322,19 @@ class JobTransactionTest(TransactionTestCase):
|
|
|
216
322
|
name = "TestPass"
|
|
217
323
|
job_result = create_job_result_and_run_job(module, name)
|
|
218
324
|
self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
|
|
325
|
+
self.assertEqual(job_result.result, True)
|
|
326
|
+
logs = job_result.job_log_entries
|
|
327
|
+
self.assertGreater(logs.count(), 0)
|
|
328
|
+
try:
|
|
329
|
+
logs.get(message="before_start() was called as expected")
|
|
330
|
+
logs.get(message="Success")
|
|
331
|
+
logs.get(message="on_success() was called as expected")
|
|
332
|
+
logs.get(message="after_return() was called as expected")
|
|
333
|
+
except models.JobLogEntry.DoesNotExist:
|
|
334
|
+
for log in logs.all():
|
|
335
|
+
print(log.message)
|
|
336
|
+
print(job_result.traceback)
|
|
337
|
+
raise
|
|
219
338
|
|
|
220
339
|
def test_job_result_manager_censor_sensitive_variables(self):
|
|
221
340
|
"""
|
|
@@ -237,6 +356,18 @@ class JobTransactionTest(TransactionTestCase):
|
|
|
237
356
|
name = "TestFail"
|
|
238
357
|
job_result = create_job_result_and_run_job(module, name)
|
|
239
358
|
self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
|
|
359
|
+
logs = job_result.job_log_entries
|
|
360
|
+
self.assertGreater(logs.count(), 0)
|
|
361
|
+
try:
|
|
362
|
+
logs.get(message="before_start() was called as expected")
|
|
363
|
+
logs.get(message="I'm a test job that fails!")
|
|
364
|
+
logs.get(message="on_failure() was called as expected")
|
|
365
|
+
logs.get(message="after_return() was called as expected")
|
|
366
|
+
except models.JobLogEntry.DoesNotExist:
|
|
367
|
+
for log in logs.all():
|
|
368
|
+
print(log.message)
|
|
369
|
+
print(job_result.traceback)
|
|
370
|
+
raise
|
|
240
371
|
|
|
241
372
|
def test_job_fail_with_sanitization(self):
|
|
242
373
|
"""
|
|
@@ -876,6 +1007,7 @@ class JobHookReceiverTransactionTest(TransactionTestCase):
|
|
|
876
1007
|
test_location = Location.objects.get(name="test_jhr")
|
|
877
1008
|
oc = get_changes_for_model(test_location).first()
|
|
878
1009
|
self.assertEqual(oc.change_context, ObjectChangeEventContextChoices.CONTEXT_JOB_HOOK)
|
|
1010
|
+
self.assertIsNotNone(job_result.user)
|
|
879
1011
|
self.assertEqual(oc.user_id, job_result.user.pk)
|
|
880
1012
|
|
|
881
1013
|
def test_missing_receive_job_hook_method(self):
|
|
@@ -933,6 +1065,8 @@ class JobHookTransactionTest(TransactionTestCase): # TODO: BaseModelTestCase mi
|
|
|
933
1065
|
module = "job_hook_receiver"
|
|
934
1066
|
name = "TestJobHookReceiverLog"
|
|
935
1067
|
self.job_class, self.job_model = get_job_class_and_model(module, name)
|
|
1068
|
+
self.assertIsNotNone(self.job_class)
|
|
1069
|
+
self.assertIsNotNone(self.job_model)
|
|
936
1070
|
job_hook = models.JobHook(
|
|
937
1071
|
name="JobHookTest",
|
|
938
1072
|
type_create=True,
|
|
@@ -65,6 +65,7 @@ from nautobot.extras.models import (
|
|
|
65
65
|
Webhook,
|
|
66
66
|
)
|
|
67
67
|
from nautobot.extras.models.statuses import StatusModel
|
|
68
|
+
from nautobot.extras.registry import registry
|
|
68
69
|
from nautobot.extras.secrets.exceptions import SecretParametersError, SecretProviderError, SecretValueNotFoundError
|
|
69
70
|
from nautobot.ipam.models import IPAddress
|
|
70
71
|
from nautobot.tenancy.models import Tenant
|
|
@@ -1079,25 +1080,31 @@ class JobModelTest(ModelTestCases.BaseModelTestCase):
|
|
|
1079
1080
|
def test_defaults(self):
|
|
1080
1081
|
"""Verify that defaults for discovered JobModel instances are as expected."""
|
|
1081
1082
|
for job_model in JobModel.objects.all():
|
|
1082
|
-
self.
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1083
|
+
with self.subTest(class_path=job_model.class_path):
|
|
1084
|
+
try:
|
|
1085
|
+
self.assertTrue(job_model.installed)
|
|
1086
|
+
# System jobs should be enabled by default, all others are disabled by default
|
|
1087
|
+
if job_model.module_name.startswith("nautobot."):
|
|
1088
|
+
self.assertTrue(job_model.enabled)
|
|
1089
|
+
else:
|
|
1090
|
+
self.assertFalse(job_model.enabled)
|
|
1091
|
+
for field_name in JOB_OVERRIDABLE_FIELDS:
|
|
1092
|
+
if field_name == "name" and "duplicate_name" in job_model.job_class.__module__:
|
|
1093
|
+
pass # name field for test_duplicate_name jobs tested in test_duplicate_job_name below
|
|
1094
|
+
else:
|
|
1095
|
+
self.assertFalse(
|
|
1096
|
+
getattr(job_model, f"{field_name}_override"),
|
|
1097
|
+
(field_name, getattr(job_model, field_name), getattr(job_model.job_class, field_name)),
|
|
1098
|
+
)
|
|
1099
|
+
self.assertEqual(
|
|
1100
|
+
getattr(job_model, field_name),
|
|
1101
|
+
getattr(job_model.job_class, field_name),
|
|
1102
|
+
field_name,
|
|
1103
|
+
)
|
|
1104
|
+
except AssertionError:
|
|
1105
|
+
print(list(JobModel.objects.all()))
|
|
1106
|
+
print(registry["jobs"])
|
|
1107
|
+
raise
|
|
1101
1108
|
|
|
1102
1109
|
def test_duplicate_job_name(self):
|
|
1103
1110
|
self.assertTrue(JobModel.objects.filter(name="TestDuplicateNameNoMeta").exists())
|
|
@@ -9,7 +9,6 @@ from django.urls import NoReverseMatch, reverse
|
|
|
9
9
|
import netaddr
|
|
10
10
|
|
|
11
11
|
from nautobot.circuits.models import Circuit, CircuitType, Provider
|
|
12
|
-
from nautobot.core.celery import app
|
|
13
12
|
from nautobot.core.testing import APIViewTestCases, disable_warnings, extract_page_body, TestCase, ViewTestCases
|
|
14
13
|
from nautobot.dcim.models import Device, DeviceType, Location, LocationType, Manufacturer
|
|
15
14
|
from nautobot.dcim.tests.test_views import create_test_device
|
|
@@ -114,9 +113,8 @@ class AppTest(TestCase):
|
|
|
114
113
|
"""
|
|
115
114
|
from example_app.jobs import ExampleJob
|
|
116
115
|
|
|
117
|
-
self.assertIn(ExampleJob, registry.get("
|
|
116
|
+
self.assertIn(ExampleJob.class_path, registry.get("jobs", {}))
|
|
118
117
|
self.assertEqual(ExampleJob, get_job("example_app.jobs.ExampleJob"))
|
|
119
|
-
self.assertIn("example_app.jobs.ExampleJob", app.tasks)
|
|
120
118
|
|
|
121
119
|
def test_git_datasource_contents_registration(self):
|
|
122
120
|
"""
|
|
@@ -1765,10 +1765,8 @@ class JobTestCase(
|
|
|
1765
1765
|
model = Job
|
|
1766
1766
|
|
|
1767
1767
|
def _get_queryset(self):
|
|
1768
|
-
"""Don't include hidden Jobs
|
|
1769
|
-
return self.model.objects.filter(
|
|
1770
|
-
installed=True, hidden=False, is_job_hook_receiver=False, is_job_button_receiver=False
|
|
1771
|
-
)
|
|
1768
|
+
"""Don't include hidden Jobs or non-installed Jobs, as they won't appear in the UI by default."""
|
|
1769
|
+
return self.model.objects.filter(installed=True, hidden=False)
|
|
1772
1770
|
|
|
1773
1771
|
@classmethod
|
|
1774
1772
|
def setUpTestData(cls):
|
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/extras/views.py
CHANGED
|
@@ -404,10 +404,8 @@ class ContactAssociationUIViewSet(
|
|
|
404
404
|
non_filter_params = ("export", "page", "per_page", "sort")
|
|
405
405
|
|
|
406
406
|
|
|
407
|
-
class
|
|
408
|
-
|
|
409
|
-
model_form = forms.ObjectNewContactForm
|
|
410
|
-
template_name = "extras/object_new_contact.html"
|
|
407
|
+
class ObjectContactTeamMixin:
|
|
408
|
+
"""Mixin that contains a custom post() method to create a new contact/team and assign it to an existing object"""
|
|
411
409
|
|
|
412
410
|
def post(self, request, *args, **kwargs):
|
|
413
411
|
obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
|
|
@@ -428,13 +426,22 @@ class ObjectNewContactView(generic.ObjectEditView):
|
|
|
428
426
|
if hasattr(form, "save_note") and callable(form.save_note):
|
|
429
427
|
form.save_note(instance=obj, user=request.user)
|
|
430
428
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
429
|
+
if isinstance(obj, Contact):
|
|
430
|
+
association = ContactAssociation(
|
|
431
|
+
contact=obj,
|
|
432
|
+
associated_object_type=ContentType.objects.get(id=request.POST.get("associated_object_type")),
|
|
433
|
+
associated_object_id=request.POST.get("associated_object_id"),
|
|
434
|
+
status=Status.objects.get(id=request.POST.get("status")),
|
|
435
|
+
role=Role.objects.get(id=request.POST.get("role")) if request.POST.get("role") else None,
|
|
436
|
+
)
|
|
437
|
+
else:
|
|
438
|
+
association = ContactAssociation(
|
|
439
|
+
team=obj,
|
|
440
|
+
associated_object_type=ContentType.objects.get(id=request.POST.get("associated_object_type")),
|
|
441
|
+
associated_object_id=request.POST.get("associated_object_id"),
|
|
442
|
+
status=Status.objects.get(id=request.POST.get("status")),
|
|
443
|
+
role=Role.objects.get(id=request.POST.get("role")) if request.POST.get("role") else None,
|
|
444
|
+
)
|
|
438
445
|
association.validated_save()
|
|
439
446
|
self.successful_post(request, obj, object_created, logger)
|
|
440
447
|
|
|
@@ -474,75 +481,17 @@ class ObjectNewContactView(generic.ObjectEditView):
|
|
|
474
481
|
)
|
|
475
482
|
|
|
476
483
|
|
|
477
|
-
class
|
|
484
|
+
class ObjectNewContactView(ObjectContactTeamMixin, generic.ObjectEditView):
|
|
485
|
+
queryset = Contact.objects.all()
|
|
486
|
+
model_form = forms.ObjectNewContactForm
|
|
487
|
+
template_name = "extras/object_new_contact.html"
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class ObjectNewTeamView(ObjectContactTeamMixin, generic.ObjectEditView):
|
|
478
491
|
queryset = Team.objects.all()
|
|
479
492
|
model_form = forms.ObjectNewTeamForm
|
|
480
493
|
template_name = "extras/object_new_team.html"
|
|
481
494
|
|
|
482
|
-
def post(self, request, *args, **kwargs):
|
|
483
|
-
obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
|
|
484
|
-
form = self.model_form(data=request.POST, files=request.FILES, instance=obj)
|
|
485
|
-
restrict_form_fields(form, request.user)
|
|
486
|
-
|
|
487
|
-
if form.is_valid():
|
|
488
|
-
logger.debug("Form validation was successful")
|
|
489
|
-
|
|
490
|
-
try:
|
|
491
|
-
with transaction.atomic():
|
|
492
|
-
object_created = not form.instance.present_in_database
|
|
493
|
-
obj = form.save()
|
|
494
|
-
|
|
495
|
-
# Check that the new object conforms with any assigned object-level permissions
|
|
496
|
-
self.queryset.get(pk=obj.pk)
|
|
497
|
-
|
|
498
|
-
if hasattr(form, "save_note") and callable(form.save_note):
|
|
499
|
-
form.save_note(instance=obj, user=request.user)
|
|
500
|
-
|
|
501
|
-
association = ContactAssociation(
|
|
502
|
-
team=obj,
|
|
503
|
-
associated_object_type=ContentType.objects.get(id=request.POST.get("associated_object_type")),
|
|
504
|
-
associated_object_id=request.POST.get("associated_object_id"),
|
|
505
|
-
status=Status.objects.get(id=request.POST.get("status")),
|
|
506
|
-
role=Role.objects.get(id=request.POST.get("role")) if request.POST.get("role") else None,
|
|
507
|
-
)
|
|
508
|
-
association.validated_save()
|
|
509
|
-
self.successful_post(request, obj, object_created, logger)
|
|
510
|
-
|
|
511
|
-
if "_addanother" in request.POST:
|
|
512
|
-
# If the object has clone_fields, pre-populate a new instance of the form
|
|
513
|
-
if hasattr(obj, "clone_fields"):
|
|
514
|
-
url = f"{request.path}?{prepare_cloned_fields(obj)}"
|
|
515
|
-
return redirect(url)
|
|
516
|
-
|
|
517
|
-
return redirect(request.get_full_path())
|
|
518
|
-
|
|
519
|
-
return_url = form.cleaned_data.get("return_url")
|
|
520
|
-
if url_has_allowed_host_and_scheme(url=return_url, allowed_hosts=request.get_host()):
|
|
521
|
-
return redirect(iri_to_uri(return_url))
|
|
522
|
-
else:
|
|
523
|
-
return redirect(self.get_return_url(request, obj))
|
|
524
|
-
|
|
525
|
-
except ObjectDoesNotExist:
|
|
526
|
-
msg = "Object save failed due to object-level permissions violation"
|
|
527
|
-
logger.debug(msg)
|
|
528
|
-
form.add_error(None, msg)
|
|
529
|
-
|
|
530
|
-
else:
|
|
531
|
-
logger.debug("Form validation failed")
|
|
532
|
-
|
|
533
|
-
return render(
|
|
534
|
-
request,
|
|
535
|
-
self.template_name,
|
|
536
|
-
{
|
|
537
|
-
"obj": obj,
|
|
538
|
-
"obj_type": self.queryset.model._meta.verbose_name,
|
|
539
|
-
"form": form,
|
|
540
|
-
"return_url": self.get_return_url(request, obj),
|
|
541
|
-
"editing": obj.present_in_database,
|
|
542
|
-
**self.get_extra_context(request, obj),
|
|
543
|
-
},
|
|
544
|
-
)
|
|
545
|
-
|
|
546
495
|
|
|
547
496
|
class ObjectAssignContactOrTeamView(generic.ObjectEditView):
|
|
548
497
|
queryset = ContactAssociation.objects.all()
|
|
@@ -1250,7 +1199,10 @@ class JobListView(generic.ObjectListView):
|
|
|
1250
1199
|
filterset = filters.JobFilterSet
|
|
1251
1200
|
filterset_form = forms.JobFilterForm
|
|
1252
1201
|
action_buttons = ()
|
|
1253
|
-
non_filter_params = (
|
|
1202
|
+
non_filter_params = (
|
|
1203
|
+
*generic.ObjectListView.non_filter_params,
|
|
1204
|
+
"display",
|
|
1205
|
+
)
|
|
1254
1206
|
template_name = "extras/job_list.html"
|
|
1255
1207
|
|
|
1256
1208
|
def alter_queryset(self, request):
|
|
@@ -1305,11 +1257,9 @@ class JobRunView(ObjectPermissionRequiredMixin, View):
|
|
|
1305
1257
|
job_model = self._get_job_model_or_404(class_path, pk)
|
|
1306
1258
|
|
|
1307
1259
|
try:
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
# job_class may be None
|
|
1312
|
-
raise RuntimeError("Job code for this job is not currently installed or loadable") from exc
|
|
1260
|
+
job_class = get_job(job_model.class_path, reload=True)
|
|
1261
|
+
if job_class is None:
|
|
1262
|
+
raise RuntimeError("Job code for this job is not currently installed or loadable")
|
|
1313
1263
|
initial = normalize_querydict(request.GET, form_class=job_class.as_form_class())
|
|
1314
1264
|
if "kwargs_from_job_result" in initial:
|
|
1315
1265
|
job_result_pk = initial.pop("kwargs_from_job_result")
|
|
@@ -1357,7 +1307,8 @@ class JobRunView(ObjectPermissionRequiredMixin, View):
|
|
|
1357
1307
|
def post(self, request, class_path=None, pk=None):
|
|
1358
1308
|
job_model = self._get_job_model_or_404(class_path, pk)
|
|
1359
1309
|
|
|
1360
|
-
|
|
1310
|
+
job_class = get_job(job_model.class_path, reload=True)
|
|
1311
|
+
job_form = job_class.as_form(request.POST, request.FILES) if job_class is not None else None
|
|
1361
1312
|
schedule_form = forms.JobScheduleForm(request.POST)
|
|
1362
1313
|
task_queue = request.POST.get("_task_queue")
|
|
1363
1314
|
|
|
@@ -1370,7 +1321,7 @@ class JobRunView(ObjectPermissionRequiredMixin, View):
|
|
|
1370
1321
|
# Allow execution only if a worker process is running and the job is runnable.
|
|
1371
1322
|
if not get_worker_count(queue=task_queue):
|
|
1372
1323
|
messages.error(request, "Unable to run or schedule job: Celery worker process not running.")
|
|
1373
|
-
elif not job_model.installed or
|
|
1324
|
+
elif not job_model.installed or job_class is None:
|
|
1374
1325
|
messages.error(request, "Unable to run or schedule job: Job is not presently installed.")
|
|
1375
1326
|
elif not job_model.enabled:
|
|
1376
1327
|
messages.error(request, "Unable to run or schedule job: Job is not enabled to be run.")
|
|
@@ -1422,11 +1373,11 @@ class JobRunView(ObjectPermissionRequiredMixin, View):
|
|
|
1422
1373
|
celery_kwargs = {"nautobot_job_profile": profile, "queue": task_queue}
|
|
1423
1374
|
scheduled_job = ScheduledJob(
|
|
1424
1375
|
name=schedule_name,
|
|
1425
|
-
task=job_model.
|
|
1376
|
+
task=job_model.class_path,
|
|
1426
1377
|
job_model=job_model,
|
|
1427
1378
|
start_time=schedule_datetime,
|
|
1428
1379
|
description=f"Nautobot job {schedule_name} scheduled by {request.user} for {schedule_datetime}",
|
|
1429
|
-
kwargs=
|
|
1380
|
+
kwargs=job_class.serialize_data(job_form.cleaned_data),
|
|
1430
1381
|
celery_kwargs=celery_kwargs,
|
|
1431
1382
|
interval=schedule_type,
|
|
1432
1383
|
one_off=schedule_type == JobExecutionType.TYPE_FUTURE,
|
|
@@ -1446,13 +1397,13 @@ class JobRunView(ObjectPermissionRequiredMixin, View):
|
|
|
1446
1397
|
|
|
1447
1398
|
else:
|
|
1448
1399
|
# Enqueue job for immediate execution
|
|
1449
|
-
job_kwargs =
|
|
1400
|
+
job_kwargs = job_class.prepare_job_kwargs(job_form.cleaned_data)
|
|
1450
1401
|
job_result = JobResult.enqueue_job(
|
|
1451
1402
|
job_model,
|
|
1452
1403
|
request.user,
|
|
1453
1404
|
profile=profile,
|
|
1454
1405
|
task_queue=task_queue,
|
|
1455
|
-
**
|
|
1406
|
+
**job_class.serialize_data(job_kwargs),
|
|
1456
1407
|
)
|
|
1457
1408
|
|
|
1458
1409
|
if return_url:
|
|
@@ -1471,10 +1422,10 @@ class JobRunView(ObjectPermissionRequiredMixin, View):
|
|
|
1471
1422
|
return redirect(return_url)
|
|
1472
1423
|
|
|
1473
1424
|
template_name = "extras/job.html"
|
|
1474
|
-
if
|
|
1425
|
+
if job_class is not None and hasattr(job_class, "template_name"):
|
|
1475
1426
|
try:
|
|
1476
|
-
get_template(
|
|
1477
|
-
template_name =
|
|
1427
|
+
get_template(job_class.template_name)
|
|
1428
|
+
template_name = job_class.template_name
|
|
1478
1429
|
except TemplateDoesNotExist as err:
|
|
1479
1430
|
messages.error(request, f'Unable to render requested custom job template "{template_name}": {err}')
|
|
1480
1431
|
|
|
@@ -1555,7 +1506,7 @@ class JobApprovalRequestView(generic.ObjectView):
|
|
|
1555
1506
|
"""
|
|
1556
1507
|
job_model = instance.job_model
|
|
1557
1508
|
if job_model is not None:
|
|
1558
|
-
job_class = job_model.
|
|
1509
|
+
job_class = get_job(job_model.class_path, reload=True)
|
|
1559
1510
|
else:
|
|
1560
1511
|
# 2.0 TODO: remove this fallback?
|
|
1561
1512
|
job_class = get_job(instance.job_class)
|
|
@@ -1591,6 +1542,7 @@ class JobApprovalRequestView(generic.ObjectView):
|
|
|
1591
1542
|
dry_run = "_dry_run" in post_data
|
|
1592
1543
|
|
|
1593
1544
|
job_model = scheduled_job.job_model
|
|
1545
|
+
job_class = get_job(job_model.class_path, reload=True)
|
|
1594
1546
|
|
|
1595
1547
|
if dry_run:
|
|
1596
1548
|
# To dry-run a job, a user needs the same permissions that would be needed to run the job directly
|
|
@@ -1604,13 +1556,13 @@ class JobApprovalRequestView(generic.ObjectView):
|
|
|
1604
1556
|
messages.error(request, "This job does not support dryrun")
|
|
1605
1557
|
else:
|
|
1606
1558
|
# Immediately enqueue the job and send the user to the normal JobResult view
|
|
1607
|
-
job_kwargs =
|
|
1559
|
+
job_kwargs = job_class.prepare_job_kwargs(scheduled_job.kwargs or {})
|
|
1608
1560
|
job_kwargs["dryrun"] = True
|
|
1609
1561
|
job_result = JobResult.enqueue_job(
|
|
1610
1562
|
job_model,
|
|
1611
1563
|
request.user,
|
|
1612
1564
|
celery_kwargs=scheduled_job.celery_kwargs,
|
|
1613
|
-
**
|
|
1565
|
+
**job_class.serialize_data(job_kwargs),
|
|
1614
1566
|
)
|
|
1615
1567
|
|
|
1616
1568
|
return redirect("extras:jobresult", pk=job_result.pk)
|
|
@@ -1697,7 +1649,7 @@ class ScheduledJobView(generic.ObjectView):
|
|
|
1697
1649
|
for name, var in job_class._get_vars().items():
|
|
1698
1650
|
field = var.as_field()
|
|
1699
1651
|
if field.label:
|
|
1700
|
-
labels[name] =
|
|
1652
|
+
labels[name] = field.label
|
|
1701
1653
|
else:
|
|
1702
1654
|
labels[name] = pretty_name(name)
|
|
1703
1655
|
return {"labels": labels, "job_class_found": (job_class is not None)}
|
nautobot/ipam/api/views.py
CHANGED
|
@@ -160,7 +160,13 @@ class PrefixViewSet(NautobotModelViewSet):
|
|
|
160
160
|
|
|
161
161
|
@extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)})
|
|
162
162
|
@extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=False)})
|
|
163
|
-
@action(
|
|
163
|
+
@action(
|
|
164
|
+
detail=True,
|
|
165
|
+
name="Available Prefixes",
|
|
166
|
+
url_path="available-prefixes",
|
|
167
|
+
methods=["get", "post"],
|
|
168
|
+
filterset_class=None,
|
|
169
|
+
)
|
|
164
170
|
def available_prefixes(self, request, pk=None):
|
|
165
171
|
"""
|
|
166
172
|
A convenience method for returning available child prefixes within a parent.
|
|
@@ -237,6 +243,7 @@ class PrefixViewSet(NautobotModelViewSet):
|
|
|
237
243
|
)
|
|
238
244
|
@action(
|
|
239
245
|
detail=True,
|
|
246
|
+
name="Available IPs",
|
|
240
247
|
url_path="available-ips",
|
|
241
248
|
methods=["get", "post"],
|
|
242
249
|
queryset=IPAddress.objects.all(),
|
nautobot/ipam/graphql/types.py
CHANGED
|
@@ -26,6 +26,7 @@ class PrefixType(OptimizedNautobotObjectType):
|
|
|
26
26
|
prefix = graphene.String()
|
|
27
27
|
ip_version = graphene.Int()
|
|
28
28
|
dynamic_groups = graphene.List("nautobot.extras.graphql.types.DynamicGroupType")
|
|
29
|
+
location = graphene.Field("nautobot.dcim.graphql.types.LocationType")
|
|
29
30
|
|
|
30
31
|
class Meta:
|
|
31
32
|
model = models.Prefix
|
|
@@ -33,3 +34,13 @@ class PrefixType(OptimizedNautobotObjectType):
|
|
|
33
34
|
|
|
34
35
|
def resolve_dynamic_groups(self, args):
|
|
35
36
|
return DynamicGroup.objects.get_for_object(self, use_cache=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class VLANType(OptimizedNautobotObjectType):
|
|
40
|
+
"""Graphql Type Object for VLAN model."""
|
|
41
|
+
|
|
42
|
+
location = graphene.Field("nautobot.dcim.graphql.types.LocationType")
|
|
43
|
+
|
|
44
|
+
class Meta:
|
|
45
|
+
model = models.VLAN
|
|
46
|
+
filterset_class = filters.VLANFilterSet
|
nautobot/ipam/mixins.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
class LocationToLocationsQuerySetMixin:
|
|
2
|
+
"""
|
|
3
|
+
A mixin for Django QuerySets to support backward compatibility by converting
|
|
4
|
+
queries from a previously used 'location' field to the new
|
|
5
|
+
'locations'. This mixin intercepts `filter` and `exclude` calls
|
|
6
|
+
to transform references from 'location' to 'locations'.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
def _convert_location_to_locations(self, kwargs):
|
|
10
|
+
"""Transforms query parameters that reference 'location' field into the corresponding 'locations' field."""
|
|
11
|
+
updated_kwargs = {}
|
|
12
|
+
for field, value in kwargs.items():
|
|
13
|
+
if field == "location":
|
|
14
|
+
# If there is no lookup expression, it means 'location' is queried directly,
|
|
15
|
+
# thus use 'locations__in' to accommodate the ManyToMany relationship
|
|
16
|
+
updated_kwargs["locations__in"] = [value]
|
|
17
|
+
elif field.startswith("location__"):
|
|
18
|
+
# If there is a lookup expression following 'location', prepend it with 'locations'
|
|
19
|
+
_, lookup_expr = field.split("location", maxsplit=1)
|
|
20
|
+
locations_field = f"locations{lookup_expr}".strip()
|
|
21
|
+
updated_kwargs[locations_field] = value
|
|
22
|
+
else:
|
|
23
|
+
updated_kwargs[field] = value
|
|
24
|
+
return updated_kwargs
|
|
25
|
+
|
|
26
|
+
def filter(self, *args, **kwargs):
|
|
27
|
+
kwargs = self._convert_location_to_locations(kwargs)
|
|
28
|
+
return super().filter(*args, **kwargs)
|
|
29
|
+
|
|
30
|
+
def exclude(self, *args, **kwargs):
|
|
31
|
+
kwargs = self._convert_location_to_locations(kwargs)
|
|
32
|
+
return super().exclude(*args, **kwargs)
|