nautobot 2.2.2__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 (88) 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 +19 -0
  20. nautobot/core/tests/test_views_utils.py +22 -1
  21. nautobot/core/utils/module_loading.py +89 -0
  22. nautobot/core/views/utils.py +3 -2
  23. nautobot/dcim/choices.py +14 -0
  24. nautobot/dcim/forms.py +51 -1
  25. nautobot/dcim/models/device_components.py +9 -5
  26. nautobot/dcim/templates/dcim/location.html +32 -13
  27. nautobot/dcim/templates/dcim/location_migrate_data_to_contact.html +102 -0
  28. nautobot/dcim/tests/test_views.py +137 -0
  29. nautobot/dcim/urls.py +5 -0
  30. nautobot/dcim/views.py +149 -1
  31. nautobot/extras/api/views.py +21 -10
  32. nautobot/extras/constants.py +3 -3
  33. nautobot/extras/datasources/git.py +47 -58
  34. nautobot/extras/forms/forms.py +3 -1
  35. nautobot/extras/jobs.py +79 -146
  36. nautobot/extras/models/datasources.py +0 -2
  37. nautobot/extras/models/jobs.py +36 -18
  38. nautobot/extras/plugins/__init__.py +1 -20
  39. nautobot/extras/signals.py +6 -9
  40. nautobot/extras/test_jobs/__init__.py +8 -0
  41. nautobot/extras/test_jobs/dry_run.py +3 -2
  42. nautobot/extras/test_jobs/fail.py +43 -0
  43. nautobot/extras/test_jobs/ipaddress_vars.py +40 -1
  44. nautobot/extras/test_jobs/jobs_module/__init__.py +5 -0
  45. nautobot/extras/test_jobs/jobs_module/jobs_submodule/__init__.py +1 -0
  46. nautobot/extras/test_jobs/jobs_module/jobs_submodule/jobs.py +6 -0
  47. nautobot/extras/test_jobs/pass.py +40 -0
  48. nautobot/extras/test_jobs/relative_import.py +11 -0
  49. nautobot/extras/tests/test_api.py +3 -0
  50. nautobot/extras/tests/test_datasources.py +125 -118
  51. nautobot/extras/tests/test_job_variables.py +57 -15
  52. nautobot/extras/tests/test_jobs.py +135 -1
  53. nautobot/extras/tests/test_models.py +26 -19
  54. nautobot/extras/tests/test_plugins.py +1 -3
  55. nautobot/extras/tests/test_views.py +2 -4
  56. nautobot/extras/views.py +47 -95
  57. nautobot/ipam/api/views.py +8 -1
  58. nautobot/ipam/graphql/types.py +11 -0
  59. nautobot/ipam/mixins.py +32 -0
  60. nautobot/ipam/models.py +2 -1
  61. nautobot/ipam/querysets.py +6 -1
  62. nautobot/ipam/tests/test_models.py +82 -0
  63. nautobot/project-static/docs/assets/extra.css +4 -0
  64. nautobot/project-static/docs/code-reference/nautobot/apps/api.html +1 -1
  65. nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +180 -211
  66. nautobot/project-static/docs/development/apps/api/platform-features/jobs.html +1 -1
  67. nautobot/project-static/docs/development/core/application-registry.html +126 -84
  68. nautobot/project-static/docs/development/core/model-checklist.html +49 -1
  69. nautobot/project-static/docs/development/core/model-features.html +1 -1
  70. nautobot/project-static/docs/development/jobs/index.html +334 -58
  71. nautobot/project-static/docs/development/jobs/migration/from-v1.html +1 -1
  72. nautobot/project-static/docs/objects.inv +0 -0
  73. nautobot/project-static/docs/release-notes/version-2.2.html +237 -55
  74. nautobot/project-static/docs/search/search_index.json +1 -1
  75. nautobot/project-static/docs/sitemap.xml +254 -254
  76. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  77. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +7 -4
  78. nautobot/project-static/docs/user-guide/core-data-model/ipam/vlan.html +111 -0
  79. nautobot/project-static/docs/user-guide/platform-functionality/jobs/index.html +15 -28
  80. nautobot/project-static/docs/user-guide/platform-functionality/jobs/models.html +4 -4
  81. nautobot/project-static/js/forms.js +18 -11
  82. {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/METADATA +3 -3
  83. {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/RECORD +87 -81
  84. nautobot/extras/test_jobs/job_variables.py +0 -93
  85. {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/LICENSE.txt +0 -0
  86. {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/NOTICE +0 -0
  87. {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/WHEEL +0 -0
  88. {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/entry_points.txt +0 -0
nautobot/extras/jobs.py CHANGED
@@ -6,18 +6,14 @@ import inspect
6
6
  import json
7
7
  import logging
8
8
  import os
9
+ import sys
9
10
  import tempfile
10
11
  from textwrap import dedent
11
12
  from typing import final
12
13
  import warnings
13
14
 
14
- from billiard.einfo import ExceptionInfo, ExceptionWithTraceback
15
- from celery import states
16
- from celery.exceptions import NotRegistered, Retry
17
- from celery.result import EagerResult
18
- from celery.utils.functional import maybe_list
15
+ from billiard.einfo import ExceptionInfo
19
16
  from celery.utils.log import get_task_logger
20
- from celery.utils.nodenames import gethostname
21
17
  from db_file_storage.form_widgets import DBClearableFileInput
22
18
  from django import forms
23
19
  from django.conf import settings
@@ -30,12 +26,10 @@ from django.db.models import Model
30
26
  from django.db.models.query import QuerySet
31
27
  from django.forms import ValidationError
32
28
  from django.utils.functional import classproperty
33
- from kombu.utils.uuid import uuid
34
29
  import netaddr
35
30
  import yaml
36
31
 
37
- from nautobot.core.celery import app as celery_app
38
- from nautobot.core.celery.task import Task
32
+ from nautobot.core.celery import import_jobs, nautobot_task
39
33
  from nautobot.core.forms import (
40
34
  DynamicModelChoiceField,
41
35
  DynamicModelMultipleChoiceField,
@@ -53,6 +47,7 @@ from nautobot.extras.models import (
53
47
  JobResult,
54
48
  ObjectChange,
55
49
  )
50
+ from nautobot.extras.registry import registry
56
51
  from nautobot.extras.utils import change_logged_models_queryset, task_queues_as_choices
57
52
  from nautobot.ipam.formfields import IPAddressFormField, IPNetworkFormField
58
53
  from nautobot.ipam.validators import (
@@ -88,7 +83,7 @@ class RunJobTaskFailed(Exception):
88
83
  """Celery task failed for some reason."""
89
84
 
90
85
 
91
- class BaseJob(Task):
86
+ class BaseJob:
92
87
  """Base model for jobs.
93
88
 
94
89
  Users can subclass this directly if they want to provide their own base class for implementing multiple jobs
@@ -158,38 +153,6 @@ class BaseJob(Task):
158
153
 
159
154
  # See https://github.com/PyCQA/pylint-django/issues/240 for why we have a pylint disable on each classproperty below
160
155
 
161
- # TODO(jathan): Could be interesting for custom stuff when the Job is
162
- # enabled in the database and then therefore registered in Celery
163
- @classmethod
164
- def on_bound(cls, app):
165
- """Called when the task is bound to an app.
166
-
167
- Note:
168
- This class method can be defined to do additional actions when
169
- the task class is bound to an app.
170
- """
171
-
172
- # TODO(jathan): Could be interesting for showing the Job's class path as the
173
- # shadow name vs. the Celery task_name?
174
- def shadow_name(self, args, kwargs, options):
175
- """Override for custom task name in worker logs/monitoring.
176
-
177
- Example:
178
- from celery.utils.imports import qualname
179
-
180
- def shadow_name(task, args, kwargs, options):
181
- return qualname(args[0])
182
-
183
- @app.task(shadow_name=shadow_name, serializer='pickle')
184
- def apply_function_async(fun, *args, **kwargs):
185
- return fun(*args, **kwargs)
186
-
187
- Arguments:
188
- args (Tuple): Task positional arguments.
189
- kwargs (Dict): Task keyword arguments.
190
- options (Dict): Task execution options.
191
- """
192
-
193
156
  def before_start(self, task_id, args, kwargs):
194
157
  """Handler called before the task starts.
195
158
 
@@ -201,8 +164,6 @@ class BaseJob(Task):
201
164
  Returns:
202
165
  (None): The return value of this handler is ignored.
203
166
  """
204
- self.clear_cache()
205
-
206
167
  try:
207
168
  self.job_result
208
169
  except ObjectDoesNotExist as err:
@@ -234,7 +195,7 @@ class BaseJob(Task):
234
195
  extra={"grouping": "initialization"},
235
196
  )
236
197
 
237
- self.logger.info("Running job", extra={"grouping": "initialization"})
198
+ self.logger.info("Running job", extra={"grouping": "initialization", "object": self.job_model})
238
199
 
239
200
  def run(self, *args, **kwargs):
240
201
  """
@@ -314,84 +275,10 @@ class BaseJob(Task):
314
275
  if status == JobResultStatusChoices.STATUS_SUCCESS:
315
276
  self.logger.info("Job completed", extra={"grouping": "post_run"})
316
277
 
317
- # TODO(gary): document this in job author docs
318
- # Super.after_return must be called for chords to function properly
319
- super().after_return(status, retval, task_id, args, kwargs, einfo=einfo)
320
-
321
- def apply(
322
- self,
323
- args=None,
324
- kwargs=None,
325
- link=None,
326
- link_error=None,
327
- task_id=None,
328
- retries=None,
329
- throw=None,
330
- logfile=None,
331
- loglevel=None,
332
- headers=None,
333
- **options,
334
- ):
335
- """Fix celery's apply method to propagate options to the task result"""
336
- # trace imports Task, so need to import inline.
337
- from celery.app.trace import build_tracer
338
-
339
- app = self._get_app()
340
- args = args or ()
341
- kwargs = kwargs or {}
342
- task_id = task_id or uuid()
343
- retries = retries or 0
344
- if throw is None:
345
- throw = app.conf.task_eager_propagates
346
-
347
- # Make sure we get the task instance, not class.
348
- task = app._tasks[self.name]
349
-
350
- request = {
351
- "id": task_id,
352
- "retries": retries,
353
- "is_eager": True,
354
- "logfile": logfile,
355
- "loglevel": loglevel or 0,
356
- "hostname": gethostname(),
357
- "callbacks": maybe_list(link),
358
- "errbacks": maybe_list(link_error),
359
- "headers": headers,
360
- "ignore_result": options.get("ignore_result", False),
361
- "delivery_info": {
362
- "is_eager": True,
363
- "exchange": options.get("exchange"),
364
- "routing_key": options.get("routing_key"),
365
- "priority": options.get("priority"),
366
- },
367
- "properties": options, # one line fix to overloaded method
368
- }
369
- if "stamped_headers" in options:
370
- request["stamped_headers"] = maybe_list(options["stamped_headers"])
371
- request["stamps"] = {header: maybe_list(options.get(header, [])) for header in request["stamped_headers"]}
372
-
373
- tb = None
374
- tracer = build_tracer(
375
- task.name,
376
- task,
377
- eager=True,
378
- propagate=throw,
379
- app=self._get_app(),
380
- )
381
- ret = tracer(task_id, args, kwargs, request)
382
- retval = ret.retval
383
- if isinstance(retval, ExceptionInfo):
384
- retval, tb = retval.exception, retval.traceback
385
- if isinstance(retval, ExceptionWithTraceback):
386
- retval = retval.exc
387
- if isinstance(retval, Retry) and retval.sig is not None:
388
- return retval.sig.apply(retries=retries + 1)
389
- state = states.SUCCESS if ret.info is None else ret.info.state
390
- return EagerResult(task_id, retval, state, traceback=tb)
391
-
392
278
  @final
393
279
  @classproperty
394
280
  def file_path(cls) -> str: # pylint: disable=no-self-argument
281
+ """Deprecated as of Nautobot 2.2.3."""
395
282
  return inspect.getfile(cls)
396
283
 
397
284
  @final
@@ -430,7 +317,7 @@ class BaseJob(Task):
430
317
  @classproperty
431
318
  def grouping(cls) -> str: # pylint: disable=no-self-argument
432
319
  module = inspect.getmodule(cls)
433
- return getattr(module, "name", module.__name__)
320
+ return getattr(module, "name", cls.__module__)
434
321
 
435
322
  @final
436
323
  @classmethod
@@ -530,6 +417,7 @@ class BaseJob(Task):
530
417
  @final
531
418
  @classproperty
532
419
  def registered_name(cls) -> str: # pylint: disable=no-self-argument
420
+ """Deprecated - use class_path classproperty instead."""
533
421
  return f"{cls.__module__}.{cls.__name__}"
534
422
 
535
423
  @classmethod
@@ -545,7 +433,10 @@ class BaseJob(Task):
545
433
  base_classes = reversed(inspect.getmro(cls))
546
434
  attr_names = [name for base in base_classes for name in base.__dict__.keys()]
547
435
  for name in attr_names:
548
- attr_class = getattr(cls, name, None).__class__
436
+ try:
437
+ attr_class = getattr(cls, name, None).__class__
438
+ except TypeError:
439
+ pass
549
440
  if name not in cls_vars and issubclass(attr_class, ScriptVariable):
550
441
  cls_vars[name] = getattr(cls, name)
551
442
 
@@ -612,27 +503,9 @@ class BaseJob(Task):
612
503
 
613
504
  return form
614
505
 
615
- def clear_cache(self):
616
- """
617
- Clear all cached properties on this instance without accessing them. This is required because
618
- celery reuses task instances for multiple runs.
619
- """
620
- try:
621
- del self.celery_kwargs
622
- except AttributeError:
623
- pass
624
- try:
625
- del self.job_result
626
- except AttributeError:
627
- pass
628
- try:
629
- del self.job_model
630
- except AttributeError:
631
- pass
632
-
633
506
  @functools.cached_property
634
507
  def job_model(self):
635
- return JobModel.objects.get(module_name=self.__module__, job_class_name=self.__name__)
508
+ return JobModel.objects.get(module_name=self.__module__, job_class_name=self.__class__.__name__)
636
509
 
637
510
  @functools.cached_property
638
511
  def job_result(self):
@@ -1199,16 +1072,76 @@ def is_variable(obj):
1199
1072
  return isinstance(obj, ScriptVariable)
1200
1073
 
1201
1074
 
1202
- def get_job(class_path):
1075
+ def get_jobs(*, reload=False):
1076
+ """
1077
+ Compile a dictionary of all Job classes available at this time.
1078
+
1079
+ Args:
1080
+ reload (bool): If True, reimport Jobs from `JOBS_ROOT` and all applicable GitRepositories.
1081
+
1082
+ Returns:
1083
+ (dict): `{"class_path.Job1": <job_class>, "class_path.Job2": <job_class>, ...}`
1084
+ """
1085
+ if reload:
1086
+ import_jobs()
1087
+
1088
+ return registry["jobs"]
1089
+
1090
+
1091
+ def get_job(class_path, reload=False):
1203
1092
  """
1204
1093
  Retrieve a specific job class by its class_path (`<module_name>.<JobClassName>`).
1205
1094
 
1206
- May return None if the job isn't properly registered with Celery at this time.
1095
+ May return None if the job can't be imported.
1096
+
1097
+ Args:
1098
+ reload (bool): If True, **and** the given class_path describes a JOBS_ROOT or GitRepository Job,
1099
+ then refresh **all** such Jobs before retrieving the job class.
1100
+ """
1101
+ if reload:
1102
+ if class_path.startswith("nautobot."):
1103
+ # System job - not reloadable
1104
+ reload = False
1105
+ if any(class_path.startswith(f"{app_name}.") for app_name in settings.PLUGINS):
1106
+ # App provided job - not reloadable
1107
+ reload = False
1108
+ jobs = get_jobs(reload=reload)
1109
+ return jobs.get(class_path, None)
1110
+
1111
+
1112
+ @nautobot_task(bind=True)
1113
+ def run_job(self, job_class_path, *args, **kwargs):
1114
+ """
1115
+ "Runner" function for execution of any Job class by a worker.
1116
+
1117
+ This calls the following Job APIs in the following order:
1118
+
1119
+ - `__init__()`
1120
+ - `before_start()`
1121
+ - `__call__()` (which calls `run()`)
1122
+ - If no exceptions have been raised, `on_success()`, else `on_failure()`
1123
+ - `after_return()`
1124
+
1125
+ Finally, it either returns the data returned from `run()` or re-raises any exception encountered.
1207
1126
  """
1127
+ logger.debug("Running job %s", job_class_path)
1128
+
1129
+ job_class = get_job(job_class_path, reload=True)
1130
+ if job_class is None:
1131
+ raise KeyError(f"Job class not found for class path {job_class_path}")
1132
+ job = job_class()
1133
+ job.request = self.request
1208
1134
  try:
1209
- return celery_app.tasks[class_path].__class__
1210
- except NotRegistered:
1211
- return None
1135
+ job.before_start(self.request.id, args, kwargs)
1136
+ result = job(*args, **kwargs)
1137
+ job.on_success(result, self.request.id, args, kwargs)
1138
+ job.after_return(JobResultStatusChoices.STATUS_SUCCESS, result, self.request.id, args, kwargs, None)
1139
+ return result
1140
+ except Exception as exc:
1141
+ einfo = ExceptionInfo(sys.exc_info())
1142
+ job.on_failure(exc, self.request.id, args, kwargs, einfo)
1143
+ job.after_return(JobResultStatusChoices.STATUS_FAILURE, exc, self.request.id, args, kwargs, einfo)
1144
+ raise
1212
1145
 
1213
1146
 
1214
1147
  def enqueue_job_hooks(object_change):
@@ -97,8 +97,6 @@ class GitRepository(PrimaryModel):
97
97
  if not self.present_in_database:
98
98
  check_if_key_is_graphql_safe(self.__class__.__name__, self.slug, "slug")
99
99
  # Check on create whether the proposed slug conflicts with a module name already in the Python environment.
100
- # Because we add GIT_ROOT to the end of sys.path, trying to import this repository will instead
101
- # import the earlier-found Python module in its place, which would be undesirable.
102
100
  if find_spec(self.slug) is not None:
103
101
  raise ValidationError(
104
102
  f'Please choose a different slug, as "{self.slug}" is an installed Python package or module.'
@@ -5,6 +5,7 @@ from datetime import timedelta
5
5
  import logging
6
6
 
7
7
  from celery import schedules
8
+ from celery.exceptions import NotRegistered
8
9
  from celery.utils.log import get_logger, LoggingProxy
9
10
  from django.conf import settings
10
11
  from django.contrib.contenttypes.models import ContentType
@@ -22,7 +23,6 @@ from nautobot.core.celery import (
22
23
  NautobotKombuJSONEncoder,
23
24
  setup_nautobot_job_logging,
24
25
  )
25
- from nautobot.core.celery.control import refresh_git_repository
26
26
  from nautobot.core.constants import CHARFIELD_MAX_LENGTH
27
27
  from nautobot.core.models import BaseManager, BaseModel
28
28
  from nautobot.core.models.fields import JSONArrayField
@@ -233,13 +233,20 @@ class Job(PrimaryModel):
233
233
  def __str__(self):
234
234
  return self.name
235
235
 
236
- @cached_property
236
+ @property
237
237
  def job_class(self):
238
- """Get the Job class (source code) associated with this Job model."""
238
+ """
239
+ Get the Job class (source code) associated with this Job model.
240
+
241
+ CAUTION: if the Job is provided by a Git Repository or is installed in JOBS_ROOT, you may need or wish to
242
+ call `get_job(self.class_path, reload=True)` to ensure that you have the latest Job code...
243
+ """
244
+ from nautobot.extras.jobs import get_job
245
+
239
246
  if not self.installed:
240
247
  return None
241
248
  try:
242
- return self.job_task.__class__
249
+ return get_job(self.class_path)
243
250
  except Exception as exc:
244
251
  logger.error(str(exc))
245
252
  return None
@@ -278,20 +285,23 @@ class Job(PrimaryModel):
278
285
 
279
286
  @property
280
287
  def job_task(self):
281
- """Get the registered Celery task, refreshing it if necessary."""
282
- if self.git_repository is not None:
283
- # If this Job comes from a Git repository, make sure we have the correct version of said code.
284
- refresh_git_repository(
285
- state=None, repository_pk=self.git_repository.pk, head=self.git_repository.current_head
286
- )
287
- return app.tasks[f"{self.module_name}.{self.job_class_name}"]
288
+ """Get an instance of the associated Job class, refreshing it if necessary."""
289
+ from nautobot.extras.jobs import get_job
290
+
291
+ try:
292
+ return get_job(self.class_path, reload=True)()
293
+ except TypeError as err: # keep 2.0-2.2.2 exception behavior
294
+ raise NotRegistered from err
288
295
 
289
296
  def clean(self):
290
297
  """For any non-overridden fields, make sure they get reset to the actual underlying class value if known."""
291
- if self.job_class is not None:
298
+ from nautobot.extras.jobs import get_job
299
+
300
+ job_class = get_job(self.class_path, reload=True)
301
+ if job_class is not None:
292
302
  for field_name in JOB_OVERRIDABLE_FIELDS:
293
303
  if not getattr(self, f"{field_name}_override", False):
294
- setattr(self, field_name, getattr(self.job_class, field_name))
304
+ setattr(self, field_name, getattr(job_class, field_name))
295
305
 
296
306
  # Protect against invalid input when auto-creating Job records
297
307
  if len(self.module_name) > JOB_MAX_NAME_LENGTH:
@@ -617,12 +627,14 @@ class JobResult(BaseModel, CustomFieldModel):
617
627
  schedule (ScheduledJob, optional): ScheduledJob instance to link to the JobResult. Cannot be used with synchronous=True.
618
628
  task_queue (str, optional): The celery queue to send the job to. If not set, use the default celery queue.
619
629
  synchronous (bool, optional): If True, run the job in the current process, blocking until the job completes.
620
- *job_args: positional args passed to the job task
630
+ *job_args: positional args passed to the job task (UNUSED)
621
631
  **job_kwargs: keyword args passed to the job task
622
632
 
623
633
  Returns:
624
634
  JobResult instance
625
635
  """
636
+ from nautobot.extras.jobs import run_job # TODO circular import
637
+
626
638
  if schedule is not None and synchronous:
627
639
  raise ValueError("Scheduled jobs cannot be run synchronously")
628
640
 
@@ -666,8 +678,11 @@ class JobResult(BaseModel, CustomFieldModel):
666
678
  redirect_logger = get_logger("celery.redirected")
667
679
  proxy = LoggingProxy(redirect_logger, app.conf.worker_redirect_stdouts_level)
668
680
  with contextlib.redirect_stdout(proxy), contextlib.redirect_stderr(proxy):
669
- eager_result = job_model.job_task.apply(
670
- args=job_args, kwargs=job_kwargs, task_id=str(job_result.id), **job_celery_kwargs
681
+ eager_result = run_job.apply(
682
+ args=[job_model.class_path, *job_args],
683
+ kwargs=job_kwargs,
684
+ task_id=str(job_result.id),
685
+ **job_celery_kwargs,
671
686
  )
672
687
 
673
688
  # copy fields from eager result to job result
@@ -687,8 +702,11 @@ class JobResult(BaseModel, CustomFieldModel):
687
702
  else:
688
703
  # Jobs queued inside of a transaction need to run after the transaction completes and the JobResult is saved to the database
689
704
  transaction.on_commit(
690
- lambda: job_model.job_task.apply_async(
691
- args=job_args, kwargs=job_kwargs, task_id=str(job_result.id), **job_celery_kwargs
705
+ lambda: run_job.apply_async(
706
+ args=[job_model.class_path, *job_args],
707
+ kwargs=job_kwargs,
708
+ task_id=str(job_result.id),
709
+ **job_celery_kwargs,
692
710
  )
693
711
  )
694
712
 
@@ -31,7 +31,6 @@ logger = getLogger(__name__)
31
31
  registry["plugin_banners"] = []
32
32
  registry["plugin_custom_validators"] = collections.defaultdict(list)
33
33
  registry["plugin_graphql_types"] = []
34
- registry["plugin_jobs"] = []
35
34
  registry["plugin_template_extensions"] = collections.defaultdict(list)
36
35
  registry["app_metrics"] = []
37
36
 
@@ -141,9 +140,9 @@ class NautobotAppConfig(NautobotConfig):
141
140
  register_graphql_types(graphql_types)
142
141
 
143
142
  # Import jobs (if present)
143
+ # Note that we do *not* auto-call `register_jobs()` - the App is responsible for doing so when imported.
144
144
  jobs = import_object(f"{self.__module__}.{self.jobs}")
145
145
  if jobs is not None:
146
- register_jobs(jobs)
147
146
  self.features["jobs"] = jobs
148
147
 
149
148
  # Import metrics (if present)
@@ -423,24 +422,6 @@ def register_graphql_types(class_list):
423
422
  registry["plugin_graphql_types"].append(item)
424
423
 
425
424
 
426
- def register_jobs(class_list):
427
- """
428
- Register a list of Job classes
429
- """
430
- from nautobot.extras.jobs import Job
431
-
432
- for job in class_list:
433
- if not inspect.isclass(job):
434
- raise TypeError(f"Job class {job} was passed as an instance!")
435
- if not issubclass(job, Job):
436
- raise TypeError(f"{job} is not a subclass of extras.jobs.Job!")
437
-
438
- registry["plugin_jobs"].append(job)
439
-
440
- # Note that we do not (and cannot) update the Job records in the Nautobot database at this time.
441
- # That is done in response to the `nautobot_database_ready` signal, see nautobot.extras.signals.refresh_job_models
442
-
443
-
444
425
  def register_metrics(function_list):
445
426
  """
446
427
  Register a list of metric functions
@@ -21,7 +21,7 @@ from django.utils import timezone
21
21
  from django_prometheus.models import model_deletes, model_inserts, model_updates
22
22
  import redis.exceptions
23
23
 
24
- from nautobot.core.celery import app, import_jobs_as_celery_tasks
24
+ from nautobot.core.celery import app, import_jobs
25
25
  from nautobot.core.models import BaseModel
26
26
  from nautobot.core.utils.config import get_settings_or_config
27
27
  from nautobot.core.utils.logging import sanitize
@@ -333,7 +333,7 @@ def git_repository_pre_delete(instance, **kwargs):
333
333
  app.control.broadcast("discard_git_repository", repository_slug=instance.slug)
334
334
  # But we don't have an equivalent way to broadcast to any other Django instances.
335
335
  # For now we just delete the one that we have locally and rely on other methods,
336
- # such as the import_jobs_as_celery_tasks() signal that runs on server startup,
336
+ # such as the import_jobs() signal that runs on server startup,
337
337
  # to clean up other clones as they're encountered.
338
338
  if os.path.isdir(instance.filesystem_path):
339
339
  shutil.rmtree(instance.filesystem_path)
@@ -462,7 +462,7 @@ def refresh_job_models(sender, *, apps, **kwargs):
462
462
  """
463
463
  Callback for the nautobot_database_ready signal; updates Jobs in the database based on Job source file availability.
464
464
  """
465
- from nautobot.extras.jobs import Job as JobClass # avoid circular import
465
+ from nautobot.extras.jobs import get_jobs # avoid circular import
466
466
 
467
467
  Job = apps.get_model("extras", "Job")
468
468
 
@@ -471,15 +471,12 @@ def refresh_job_models(sender, *, apps, **kwargs):
471
471
  logger.info("Skipping refresh_job_models() as it appears Job model has not yet been migrated to latest.")
472
472
  return
473
473
 
474
- import_jobs_as_celery_tasks(app)
474
+ import_jobs()
475
475
 
476
476
  job_models = []
477
- for task in app.tasks.values():
478
- # Skip Celery tasks that aren't Jobs
479
- if not isinstance(task, JobClass):
480
- continue
481
477
 
482
- job_model, _ = refresh_job_model_from_job_class(Job, task.__class__)
478
+ for job_class in get_jobs().values():
479
+ job_model, _ = refresh_job_model_from_job_class(Job, job_class)
483
480
  if job_model is not None:
484
481
  job_models.append(job_model)
485
482
 
@@ -0,0 +1,8 @@
1
+ def load_tests(*args):
2
+ """Implement unittest discovery for this submodule as a no-op.
3
+
4
+ This prevents unittest from recursively loading all of the modules under this directory to inspect whether they
5
+ define test cases. This is necessary because otherwise the `jobs_module` submodule will get loaded when tests run,
6
+ which will in turn call `register_jobs()`, incorrectly/unexpectedly registering the test Job defined in that module
7
+ as if it were a system Job, which will cause tests to fail due to the unexpected presence of this Job.
8
+ """
@@ -1,5 +1,5 @@
1
1
  from nautobot.core.celery import register_jobs
2
- from nautobot.extras.jobs import DryRunVar, get_task_logger, Job
2
+ from nautobot.extras.jobs import DryRunVar, get_task_logger, IntegerVar, Job
3
3
  from nautobot.extras.models import Status
4
4
 
5
5
  logger = get_task_logger(__name__)
@@ -11,8 +11,9 @@ class TestDryRun(Job):
11
11
  """
12
12
 
13
13
  dryrun = DryRunVar()
14
+ value = IntegerVar(required=False)
14
15
 
15
- def run(self, dryrun):
16
+ def run(self, dryrun, value=None):
16
17
  """
17
18
  Job function.
18
19
  """
@@ -1,4 +1,7 @@
1
+ from billiard.einfo import ExceptionInfo
2
+
1
3
  from nautobot.core.celery import register_jobs
4
+ from nautobot.extras.choices import JobResultStatusChoices
2
5
  from nautobot.extras.jobs import get_task_logger, Job, RunJobTaskFailed
3
6
 
4
7
  logger = get_task_logger(__name__)
@@ -11,6 +14,15 @@ class TestFail(Job):
11
14
 
12
15
  description = "Validate job import"
13
16
 
17
+ def before_start(self, task_id, args, kwargs):
18
+ if task_id != self.request.id:
19
+ raise RuntimeError(f"Expected task_id {task_id} to equal self.request.id {self.request.id}")
20
+ if args:
21
+ raise RuntimeError(f"Expected args to be empty, but it was {args!r}")
22
+ if kwargs:
23
+ raise RuntimeError(f"Expected kwargs to be empty, but it was {kwargs!r}")
24
+ logger.info("before_start() was called as expected")
25
+
14
26
  def run(self):
15
27
  """
16
28
  Job function.
@@ -18,6 +30,37 @@ class TestFail(Job):
18
30
  logger.info("I'm a test job that fails!")
19
31
  raise RunJobTaskFailed("Test failure")
20
32
 
33
+ def on_success(self, retval, task_id, args, kwargs):
34
+ raise RuntimeError("on_success() was unexpectedly called!")
35
+
36
+ def on_failure(self, exc, task_id, args, kwargs, einfo):
37
+ if not isinstance(exc, RunJobTaskFailed):
38
+ raise RuntimeError(f"Expected exc to be a RunJobTaskFailed, but it was {exc!r}")
39
+ if task_id != self.request.id:
40
+ raise RuntimeError(f"Expected task_id {task_id} to equal self.request.id {self.request.id}")
41
+ if args:
42
+ raise RuntimeError(f"Expected args to be empty, but it was {args!r}")
43
+ if kwargs:
44
+ raise RuntimeError(f"Expected kwargs to be empty, but it was {kwargs!r}")
45
+ if not isinstance(einfo, ExceptionInfo):
46
+ raise RuntimeError(f"Expected einfo to be an ExceptionInfo, but it was {einfo!r}")
47
+ logger.info("on_failure() was called as expected")
48
+
49
+ def after_return(self, status, retval, task_id, args, kwargs, einfo):
50
+ if status is not JobResultStatusChoices.STATUS_FAILURE:
51
+ raise RuntimeError(f"Expected status to be {JobResultStatusChoices.STATUS_FAILURE}, but it was {status!r}")
52
+ if not isinstance(retval, RunJobTaskFailed):
53
+ raise RuntimeError(f"Expected retval to be a RunJobTaskFailed, but it was {retval!r}")
54
+ if task_id != self.request.id:
55
+ raise RuntimeError(f"Expected task_id {task_id} to equal self.request.id {self.request.id}")
56
+ if args:
57
+ raise RuntimeError(f"Expected args to be empty, but it was {args!r}")
58
+ if kwargs:
59
+ raise RuntimeError(f"Expected kwargs to be empty, but it was {kwargs!r}")
60
+ if not isinstance(einfo, ExceptionInfo):
61
+ raise RuntimeError(f"Expected einfo to be an ExceptionInfo, but it was {einfo!r}")
62
+ logger.info("after_return() was called as expected")
63
+
21
64
 
22
65
  class TestFailWithSanitization(Job):
23
66
  """