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.
Files changed (99) hide show
  1. nautobot/apps/jobs.py +2 -0
  2. nautobot/core/api/utils.py +12 -9
  3. nautobot/core/apps/__init__.py +2 -2
  4. nautobot/core/celery/__init__.py +79 -68
  5. nautobot/core/celery/backends.py +9 -1
  6. nautobot/core/celery/control.py +4 -7
  7. nautobot/core/celery/schedulers.py +4 -2
  8. nautobot/core/celery/task.py +78 -5
  9. nautobot/core/graphql/schema.py +2 -1
  10. nautobot/core/jobs/__init__.py +2 -1
  11. nautobot/core/templates/generic/object_list.html +3 -3
  12. nautobot/core/templatetags/helpers.py +66 -9
  13. nautobot/core/testing/__init__.py +6 -1
  14. nautobot/core/testing/api.py +12 -13
  15. nautobot/core/testing/mixins.py +2 -2
  16. nautobot/core/testing/views.py +50 -51
  17. nautobot/core/tests/test_api.py +23 -2
  18. nautobot/core/tests/test_templatetags_helpers.py +32 -0
  19. nautobot/core/tests/test_views.py +21 -1
  20. nautobot/core/tests/test_views_utils.py +22 -1
  21. nautobot/core/utils/module_loading.py +89 -0
  22. nautobot/core/views/generic.py +4 -4
  23. nautobot/core/views/mixins.py +4 -3
  24. nautobot/core/views/utils.py +3 -2
  25. nautobot/core/wsgi.py +9 -2
  26. nautobot/dcim/choices.py +14 -0
  27. nautobot/dcim/forms.py +59 -4
  28. nautobot/dcim/models/device_components.py +9 -5
  29. nautobot/dcim/templates/dcim/device/lldp_neighbors.html +2 -2
  30. nautobot/dcim/templates/dcim/devicefamily_retrieve.html +1 -1
  31. nautobot/dcim/templates/dcim/location.html +32 -13
  32. nautobot/dcim/templates/dcim/location_migrate_data_to_contact.html +102 -0
  33. nautobot/dcim/tests/test_forms.py +49 -2
  34. nautobot/dcim/tests/test_views.py +137 -0
  35. nautobot/dcim/urls.py +5 -0
  36. nautobot/dcim/views.py +149 -1
  37. nautobot/extras/api/views.py +21 -10
  38. nautobot/extras/constants.py +3 -3
  39. nautobot/extras/context_managers.py +56 -0
  40. nautobot/extras/datasources/git.py +47 -58
  41. nautobot/extras/forms/forms.py +3 -1
  42. nautobot/extras/jobs.py +79 -146
  43. nautobot/extras/models/datasources.py +0 -2
  44. nautobot/extras/models/jobs.py +36 -18
  45. nautobot/extras/plugins/__init__.py +1 -20
  46. nautobot/extras/signals.py +88 -57
  47. nautobot/extras/test_jobs/__init__.py +8 -0
  48. nautobot/extras/test_jobs/dry_run.py +3 -2
  49. nautobot/extras/test_jobs/fail.py +43 -0
  50. nautobot/extras/test_jobs/ipaddress_vars.py +40 -1
  51. nautobot/extras/test_jobs/jobs_module/__init__.py +5 -0
  52. nautobot/extras/test_jobs/jobs_module/jobs_submodule/__init__.py +1 -0
  53. nautobot/extras/test_jobs/jobs_module/jobs_submodule/jobs.py +6 -0
  54. nautobot/extras/test_jobs/pass.py +40 -0
  55. nautobot/extras/test_jobs/relative_import.py +11 -0
  56. nautobot/extras/tests/test_api.py +3 -0
  57. nautobot/extras/tests/test_context_managers.py +98 -1
  58. nautobot/extras/tests/test_datasources.py +125 -118
  59. nautobot/extras/tests/test_job_variables.py +57 -15
  60. nautobot/extras/tests/test_jobs.py +135 -1
  61. nautobot/extras/tests/test_models.py +26 -19
  62. nautobot/extras/tests/test_plugins.py +1 -3
  63. nautobot/extras/tests/test_views.py +2 -4
  64. nautobot/extras/utils.py +37 -0
  65. nautobot/extras/views.py +47 -95
  66. nautobot/ipam/api/views.py +8 -1
  67. nautobot/ipam/graphql/types.py +11 -0
  68. nautobot/ipam/mixins.py +32 -0
  69. nautobot/ipam/models.py +2 -1
  70. nautobot/ipam/querysets.py +6 -1
  71. nautobot/ipam/tables.py +1 -1
  72. nautobot/ipam/tests/test_models.py +82 -0
  73. nautobot/project-static/docs/assets/extra.css +4 -0
  74. nautobot/project-static/docs/code-reference/nautobot/apps/api.html +1 -1
  75. nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +180 -211
  76. nautobot/project-static/docs/development/apps/api/platform-features/jobs.html +1 -1
  77. nautobot/project-static/docs/development/core/application-registry.html +126 -84
  78. nautobot/project-static/docs/development/core/model-checklist.html +49 -1
  79. nautobot/project-static/docs/development/core/model-features.html +1 -1
  80. nautobot/project-static/docs/development/jobs/index.html +334 -58
  81. nautobot/project-static/docs/development/jobs/migration/from-v1.html +1 -1
  82. nautobot/project-static/docs/objects.inv +0 -0
  83. nautobot/project-static/docs/release-notes/version-1.6.html +504 -201
  84. nautobot/project-static/docs/release-notes/version-2.2.html +392 -43
  85. nautobot/project-static/docs/search/search_index.json +1 -1
  86. nautobot/project-static/docs/sitemap.xml +254 -254
  87. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  88. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +7 -4
  89. nautobot/project-static/docs/user-guide/core-data-model/ipam/vlan.html +111 -0
  90. nautobot/project-static/docs/user-guide/platform-functionality/jobs/index.html +15 -28
  91. nautobot/project-static/docs/user-guide/platform-functionality/jobs/models.html +4 -4
  92. nautobot/project-static/js/forms.js +18 -11
  93. {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/METADATA +3 -3
  94. {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/RECORD +98 -92
  95. nautobot/extras/test_jobs/job_variables.py +0 -93
  96. {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/LICENSE.txt +0 -0
  97. {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/NOTICE +0 -0
  98. {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/WHEEL +0 -0
  99. {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.assertTrue(job_model.installed)
1083
- # System jobs should be enabled by default, all others are disabled by default
1084
- if job_model.module_name.startswith("nautobot."):
1085
- self.assertTrue(job_model.enabled)
1086
- else:
1087
- self.assertFalse(job_model.enabled)
1088
- for field_name in JOB_OVERRIDABLE_FIELDS:
1089
- if field_name == "name" and "duplicate_name" in job_model.job_class.__module__:
1090
- pass # name field for test_duplicate_name jobs tested in test_duplicate_job_name below
1091
- else:
1092
- self.assertFalse(
1093
- getattr(job_model, f"{field_name}_override"),
1094
- (field_name, getattr(job_model, field_name), getattr(job_model.job_class, field_name)),
1095
- )
1096
- self.assertEqual(
1097
- getattr(job_model, field_name),
1098
- getattr(job_model.job_class, field_name),
1099
- field_name,
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("plugin_jobs", []))
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, non-installed Jobs, JobHookReceivers or JobButtonReceivers as they won't appear in the UI by default."""
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 ObjectNewContactView(generic.ObjectEditView):
408
- queryset = Contact.objects.all()
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
- association = ContactAssociation(
432
- contact=obj,
433
- associated_object_type=ContentType.objects.get(id=request.POST.get("associated_object_type")),
434
- associated_object_id=request.POST.get("associated_object_id"),
435
- status=Status.objects.get(id=request.POST.get("status")),
436
- role=Role.objects.get(id=request.POST.get("role")) if request.POST.get("role") else None,
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 ObjectNewTeamView(generic.ObjectEditView):
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 = ("display",)
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
- try:
1309
- job_class = job_model.job_class
1310
- except TypeError as exc:
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
- job_form = job_model.job_class.as_form(request.POST, request.FILES) if job_model.job_class is not None else None
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 job_model.job_class is None:
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.job_class.registered_name,
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=job_model.job_class.serialize_data(job_form.cleaned_data),
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 = job_model.job_class.prepare_job_kwargs(job_form.cleaned_data)
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
- **job_model.job_class.serialize_data(job_kwargs),
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 job_model.job_class is not None and hasattr(job_model.job_class, "template_name"):
1425
+ if job_class is not None and hasattr(job_class, "template_name"):
1475
1426
  try:
1476
- get_template(job_model.job_class.template_name)
1477
- template_name = job_model.job_class.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.job_class
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 = job_model.job_class.prepare_job_kwargs(scheduled_job.kwargs or {})
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
- **job_model.job_class.serialize_data(job_kwargs),
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] = var
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)}
@@ -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(detail=True, url_path="available-prefixes", methods=["get", "post"], filterset_class=None)
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(),
@@ -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
@@ -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)