nautobot 2.4.4__py3-none-any.whl → 2.4.6__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 (139) hide show
  1. nautobot/__init__.py +19 -3
  2. nautobot/core/api/mixins.py +10 -0
  3. nautobot/core/celery/__init__.py +5 -3
  4. nautobot/core/celery/encoders.py +2 -2
  5. nautobot/core/forms/fields.py +21 -5
  6. nautobot/core/forms/utils.py +1 -0
  7. nautobot/core/jobs/__init__.py +3 -2
  8. nautobot/core/jobs/bulk_actions.py +1 -1
  9. nautobot/core/management/commands/generate_test_data.py +1 -1
  10. nautobot/core/models/name_color_content_types.py +9 -0
  11. nautobot/core/models/validators.py +7 -0
  12. nautobot/core/settings.py +0 -14
  13. nautobot/core/settings.yaml +0 -28
  14. nautobot/core/tables.py +6 -1
  15. nautobot/core/templates/generic/object_retrieve.html +1 -1
  16. nautobot/core/testing/__init__.py +2 -0
  17. nautobot/core/testing/api.py +18 -0
  18. nautobot/core/testing/mixins.py +9 -0
  19. nautobot/core/tests/nautobot_config.py +0 -2
  20. nautobot/core/tests/runner.py +17 -140
  21. nautobot/core/tests/test_api.py +4 -4
  22. nautobot/core/tests/test_authentication.py +83 -4
  23. nautobot/core/tests/test_forms.py +11 -8
  24. nautobot/core/tests/test_graphql.py +9 -0
  25. nautobot/core/tests/test_jobs.py +33 -27
  26. nautobot/core/ui/object_detail.py +31 -0
  27. nautobot/dcim/factory.py +2 -0
  28. nautobot/dcim/filters/__init__.py +5 -0
  29. nautobot/dcim/forms.py +17 -1
  30. nautobot/dcim/migrations/0068_alter_softwareimagefile_download_url.py +19 -0
  31. nautobot/dcim/migrations/0069_softwareimagefile_external_integration.py +25 -0
  32. nautobot/dcim/models/devices.py +9 -2
  33. nautobot/dcim/tables/devices.py +1 -0
  34. nautobot/dcim/templates/dcim/softwareimagefile_retrieve.html +4 -0
  35. nautobot/dcim/tests/test_api.py +74 -31
  36. nautobot/dcim/tests/test_filters.py +2 -0
  37. nautobot/dcim/tests/test_jobs.py +4 -6
  38. nautobot/dcim/tests/test_models.py +65 -0
  39. nautobot/dcim/tests/test_views.py +3 -0
  40. nautobot/extras/choices.py +8 -3
  41. nautobot/extras/forms/forms.py +7 -3
  42. nautobot/extras/jobs.py +181 -103
  43. nautobot/extras/management/utils.py +13 -2
  44. nautobot/extras/models/datasources.py +4 -1
  45. nautobot/extras/models/jobs.py +20 -17
  46. nautobot/extras/plugins/marketplace_manifest.yml +18 -0
  47. nautobot/extras/tables.py +29 -34
  48. nautobot/extras/templates/extras/inc/panel_changelog.html +1 -1
  49. nautobot/extras/templates/extras/inc/panel_jobhistory.html +1 -1
  50. nautobot/extras/templates/extras/status.html +1 -37
  51. nautobot/extras/test_jobs/atomic_transaction.py +6 -6
  52. nautobot/extras/test_jobs/fail.py +75 -1
  53. nautobot/extras/tests/integration/test_notes.py +1 -1
  54. nautobot/extras/tests/test_api.py +23 -8
  55. nautobot/extras/tests/test_changelog.py +4 -4
  56. nautobot/extras/tests/test_customfields.py +3 -0
  57. nautobot/extras/tests/test_datasources.py +64 -54
  58. nautobot/extras/tests/test_jobs.py +69 -62
  59. nautobot/extras/tests/test_models.py +1 -1
  60. nautobot/extras/tests/test_plugins.py +19 -13
  61. nautobot/extras/tests/test_relationships.py +14 -5
  62. nautobot/extras/tests/test_tags.py +2 -2
  63. nautobot/extras/tests/test_views.py +15 -6
  64. nautobot/extras/urls.py +1 -30
  65. nautobot/extras/views.py +17 -55
  66. nautobot/ipam/forms.py +15 -0
  67. nautobot/ipam/querysets.py +6 -0
  68. nautobot/ipam/tables.py +6 -2
  69. nautobot/ipam/templates/ipam/namespace_retrieve.html +0 -41
  70. nautobot/ipam/templates/ipam/rir.html +1 -43
  71. nautobot/ipam/templates/ipam/service.html +2 -46
  72. nautobot/ipam/templates/ipam/service_edit.html +1 -17
  73. nautobot/ipam/templates/ipam/service_retrieve.html +7 -0
  74. nautobot/ipam/tests/migration/__init__.py +0 -0
  75. nautobot/ipam/tests/migration/test_migrations.py +510 -0
  76. nautobot/ipam/tests/test_api.py +66 -36
  77. nautobot/ipam/tests/test_filters.py +0 -10
  78. nautobot/ipam/tests/test_models.py +16 -0
  79. nautobot/ipam/tests/test_views.py +44 -2
  80. nautobot/ipam/urls.py +2 -67
  81. nautobot/ipam/utils/migrations.py +185 -152
  82. nautobot/ipam/utils/testing.py +177 -0
  83. nautobot/ipam/views.py +119 -198
  84. nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +43 -5
  85. nautobot/project-static/docs/code-reference/nautobot/apps/models.html +47 -0
  86. nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +18 -0
  87. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +35 -0
  88. nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +63 -0
  89. nautobot/project-static/docs/development/apps/api/testing.html +0 -87
  90. nautobot/project-static/docs/development/apps/migration/dependency-updates.html +1 -1
  91. nautobot/project-static/docs/development/core/best-practices.html +3 -3
  92. nautobot/project-static/docs/development/core/getting-started.html +78 -107
  93. nautobot/project-static/docs/development/core/release-checklist.html +1 -1
  94. nautobot/project-static/docs/development/core/style-guide.html +1 -1
  95. nautobot/project-static/docs/development/core/testing.html +24 -198
  96. nautobot/project-static/docs/development/jobs/index.html +27 -14
  97. nautobot/project-static/docs/media/user-guide/administration/getting-started/nautobot-cloud.png +0 -0
  98. nautobot/project-static/docs/objects.inv +0 -0
  99. nautobot/project-static/docs/overview/application_stack.html +1 -1
  100. nautobot/project-static/docs/release-notes/version-2.4.html +409 -1
  101. nautobot/project-static/docs/requirements.txt +1 -1
  102. nautobot/project-static/docs/search/search_index.json +1 -1
  103. nautobot/project-static/docs/sitemap.xml +290 -290
  104. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  105. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +2 -48
  106. nautobot/project-static/docs/user-guide/administration/guides/permissions.html +71 -0
  107. nautobot/project-static/docs/user-guide/administration/installation/http-server.html +3 -1
  108. nautobot/project-static/docs/user-guide/administration/installation/index.html +257 -16
  109. nautobot/project-static/docs/user-guide/administration/tools/nautobot-server.html +1 -1
  110. nautobot/project-static/docs/user-guide/administration/upgrading/upgrading.html +2 -2
  111. nautobot/project-static/docs/user-guide/core-data-model/dcim/softwareimagefile.html +4 -0
  112. nautobot/project-static/docs/user-guide/feature-guides/contacts-and-teams.html +11 -11
  113. nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-devices.html +8 -8
  114. nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-location-types-and-locations.html +1 -0
  115. nautobot/project-static/docs/user-guide/feature-guides/getting-started/interfaces.html +40 -25
  116. nautobot/project-static/docs/user-guide/feature-guides/getting-started/ipam.html +4 -4
  117. nautobot/project-static/docs/user-guide/feature-guides/getting-started/platforms.html +1 -1
  118. nautobot/project-static/docs/user-guide/feature-guides/getting-started/search-bar.html +77 -5
  119. nautobot/project-static/docs/user-guide/feature-guides/getting-started/tenants.html +1 -1
  120. nautobot/project-static/docs/user-guide/feature-guides/getting-started/vlans-and-vlan-groups.html +0 -1
  121. nautobot/project-static/docs/user-guide/feature-guides/git-data-source.html +1 -1
  122. nautobot/project-static/docs/user-guide/index.html +89 -2
  123. nautobot/project-static/docs/user-guide/platform-functionality/webhook.html +207 -122
  124. nautobot/virtualization/forms.py +20 -0
  125. nautobot/virtualization/templates/virtualization/clustergroup.html +1 -39
  126. nautobot/virtualization/templates/virtualization/clustertype.html +1 -0
  127. nautobot/virtualization/tests/test_api.py +14 -3
  128. nautobot/virtualization/tests/test_views.py +10 -2
  129. nautobot/virtualization/urls.py +10 -93
  130. nautobot/virtualization/views.py +33 -72
  131. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/METADATA +8 -7
  132. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/RECORD +137 -132
  133. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/WHEEL +1 -1
  134. nautobot/core/tests/performance_baselines.yml +0 -8900
  135. nautobot/ipam/tests/test_migrations.py +0 -462
  136. /nautobot/ipam/templates/ipam/{namespace_ipaddresses.html → namespace_ip_addresses.html} +0 -0
  137. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/LICENSE.txt +0 -0
  138. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/NOTICE +0 -0
  139. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/entry_points.txt +0 -0
nautobot/extras/jobs.py CHANGED
@@ -13,13 +13,13 @@ from typing import final
13
13
  import warnings
14
14
 
15
15
  from billiard.einfo import ExceptionInfo
16
+ from celery.exceptions import Ignore, Reject
16
17
  from celery.utils.log import get_task_logger
17
18
  from db_file_storage.form_widgets import DBClearableFileInput
18
19
  from django import forms
19
20
  from django.conf import settings
20
21
  from django.contrib.auth import get_user_model
21
22
  from django.core.cache import cache
22
- from django.core.exceptions import ObjectDoesNotExist
23
23
  from django.core.files.base import ContentFile
24
24
  from django.core.files.uploadedfile import UploadedFile
25
25
  from django.core.validators import RegexValidator
@@ -121,6 +121,7 @@ class BaseJob:
121
121
 
122
122
  def __init__(self):
123
123
  self.logger = get_task_logger(self.__module__)
124
+ self._failed = False
124
125
 
125
126
  def __call__(self, *args, **kwargs):
126
127
  # Attempt to resolve serialized data back into original form by creating querysets or model instances
@@ -130,8 +131,9 @@ class BaseJob:
130
131
  try:
131
132
  deserialized_kwargs = self.deserialize_data(kwargs)
132
133
  except Exception as err:
133
- self.logger.error("%s", err)
134
+ self.logger.exception("Error deserializing kwargs")
134
135
  raise RunJobTaskFailed("Error initializing job") from err
136
+
135
137
  if isinstance(self, JobHookReceiver):
136
138
  change_context = ObjectChangeEventContextChoices.CONTEXT_JOB_HOOK
137
139
  else:
@@ -163,7 +165,11 @@ class BaseJob:
163
165
  def __str__(self):
164
166
  return str(self.name)
165
167
 
166
- # See https://github.com/PyCQA/pylint-django/issues/240 for why we have a pylint disable on each classproperty below
168
+ def fail(self, msg, *args, **kwargs):
169
+ """Mark this job as failed without immediately raising an exception and aborting."""
170
+ # Instead of failing in "fail" grouping, fail in the parent function's grouping by default
171
+ self.logger.failure(msg, *args, stacklevel=2, **kwargs)
172
+ self._failed = True
167
173
 
168
174
  def before_start(self, task_id, args, kwargs):
169
175
  """Handler called before the task starts.
@@ -174,65 +180,10 @@ class BaseJob:
174
180
  kwargs (Dict): Original keyword arguments for the task to execute.
175
181
 
176
182
  Returns:
177
- (None): The return value of this handler is ignored.
183
+ (Any): The return value of this handler is ignored normally, **except** if `self.fail()` is called herein,
184
+ in which case the return value will be used as the overall JobResult return value
185
+ since `self.run()` will **not** be called in such a case.
178
186
  """
179
- try:
180
- self.job_result
181
- except ObjectDoesNotExist as err:
182
- raise RunJobTaskFailed(f"Unable to find associated job result for job {task_id}") from err
183
-
184
- try:
185
- self.job_model
186
- except ObjectDoesNotExist as err:
187
- raise RunJobTaskFailed(f"Unable to find associated job model for job {task_id}") from err
188
-
189
- if not self.job_model.enabled:
190
- self.logger.error(
191
- "Job %s is not enabled to be run!",
192
- self.job_model,
193
- extra={"object": self.job_model, "grouping": "initialization"},
194
- )
195
- raise RunJobTaskFailed(f"Job {self.job_model} is not enabled to be run!")
196
-
197
- ignore_singleton_lock = self.celery_kwargs.get("nautobot_job_ignore_singleton_lock", False)
198
- if self.job_model.is_singleton:
199
- is_running = cache.get(self.singleton_cache_key)
200
- if is_running:
201
- if ignore_singleton_lock:
202
- self.logger.info(
203
- "Job %s is a singleton and already running, but singleton will be ignored because"
204
- " `ignore_singleton_lock` is set.",
205
- self.job_model,
206
- extra={"object": self.job_model, "grouping": "initialization"},
207
- )
208
- else:
209
- self.logger.error(
210
- "Job %s is a singleton and already running.",
211
- self.job_model,
212
- extra={"object": self.job_model, "grouping": "initialization"},
213
- )
214
- raise RunJobTaskFailed(f"Job '{self.job_model}' is a singleton and already running.")
215
- cache_parameters = {
216
- "key": self.singleton_cache_key,
217
- "value": 1,
218
- "timeout": self.job_model.time_limit or settings.CELERY_TASK_TIME_LIMIT,
219
- }
220
- cache.set(**cache_parameters)
221
-
222
- soft_time_limit = self.job_model.soft_time_limit or settings.CELERY_TASK_SOFT_TIME_LIMIT
223
- time_limit = self.job_model.time_limit or settings.CELERY_TASK_TIME_LIMIT
224
- if time_limit <= soft_time_limit:
225
- self.logger.warning(
226
- "The hard time limit of %s seconds is less than "
227
- "or equal to the soft time limit of %s seconds. "
228
- "This job will fail silently after %s seconds.",
229
- time_limit,
230
- soft_time_limit,
231
- time_limit,
232
- extra={"grouping": "initialization"},
233
- )
234
-
235
- self.logger.info("Running job", extra={"grouping": "initialization", "object": self.job_model})
236
187
 
237
188
  def run(self, *args, **kwargs):
238
189
  """
@@ -277,11 +228,12 @@ class BaseJob:
277
228
  This is run by the worker when the task fails.
278
229
 
279
230
  Arguments:
280
- exc (Exception): The exception raised by the task.
231
+ exc (Any): Exception raised by the task (if any) **or** return value from the task, if it failed cleanly,
232
+ such as if the Job called `self.fail()` rather than raising an exception.
281
233
  task_id (str): Unique id of the failed task.
282
234
  args (Tuple): Original arguments for the task that failed.
283
235
  kwargs (Dict): Original keyword arguments for the task that failed.
284
- einfo (~billiard.einfo.ExceptionInfo): Exception information.
236
+ einfo (~billiard.einfo.ExceptionInfo): Exception information, or None.
285
237
 
286
238
  Returns:
287
239
  (None): The return value of this handler is ignored.
@@ -303,16 +255,7 @@ class BaseJob:
303
255
  (None): The return value of this handler is ignored.
304
256
  """
305
257
 
306
- # Cleanup FileProxy objects
307
- file_fields = list(self._get_file_vars())
308
- file_ids = [kwargs[f] for f in file_fields if f in kwargs]
309
- if file_ids:
310
- self._delete_file_proxies(*file_ids)
311
-
312
- if status == JobResultStatusChoices.STATUS_SUCCESS:
313
- self.logger.success("Job completed", extra={"grouping": "post_run"})
314
-
315
- cache.delete(self.singleton_cache_key)
258
+ # See https://github.com/PyCQA/pylint-django/issues/240 for why we have a pylint disable on each classproperty below
316
259
 
317
260
  @final
318
261
  @classproperty
@@ -1205,6 +1148,114 @@ def get_job(class_path, reload=False):
1205
1148
  return jobs.get(class_path, None)
1206
1149
 
1207
1150
 
1151
+ def _prepare_job(job_class_path, request, kwargs) -> tuple[Job, dict]:
1152
+ """Helper method to run_job task, handling initial data setup and initialization before running a Job."""
1153
+ logger.debug("Preparing to run job %s for task %s", job_class_path, request.id)
1154
+
1155
+ # Get the job code
1156
+ job_class = get_job(job_class_path, reload=True)
1157
+ if job_class is None:
1158
+ raise KeyError(f"Job class not found for class path {job_class_path}")
1159
+ job = job_class()
1160
+ job.request = request
1161
+
1162
+ # Get the JobResult record to record results to
1163
+ try:
1164
+ job_result = JobResult.objects.get(id=request.id)
1165
+ except JobResult.DoesNotExist:
1166
+ job.logger.exception("Unable to find JobResult %s", request.id, extra={"grouping": "initialization"})
1167
+ raise
1168
+
1169
+ # Get the JobModel record associated with this job
1170
+ try:
1171
+ job.job_model
1172
+ except JobModel.DoesNotExist:
1173
+ job.logger.exception(
1174
+ "Unable to find Job database record %s", job_class_path, extra={"grouping": "initialization"}
1175
+ )
1176
+ raise
1177
+
1178
+ # Make sure it's valid to run this job - 1) is it enabled?
1179
+ if not job.job_model.enabled:
1180
+ job.logger.error(
1181
+ "Job %s is not enabled to be run!",
1182
+ job.job_model,
1183
+ extra={"object": job.job_model, "grouping": "initialization"},
1184
+ )
1185
+ raise RunJobTaskFailed(f"Job {job.job_model} is not enabled to be run!")
1186
+ # 2) if it's a singleton, is there any existing lock to be aware of?
1187
+ if job.job_model.is_singleton:
1188
+ is_running = cache.get(job.singleton_cache_key)
1189
+ if is_running:
1190
+ ignore_singleton_lock = job.celery_kwargs.get("nautobot_job_ignore_singleton_lock", False)
1191
+ if ignore_singleton_lock:
1192
+ job.logger.warning(
1193
+ "Job %s is a singleton and already running, but singleton will be ignored because"
1194
+ " `ignore_singleton_lock` is set.",
1195
+ job.job_model,
1196
+ extra={"object": job.job_model, "grouping": "initialization"},
1197
+ )
1198
+ else:
1199
+ # TODO 3.0: maybe change to logger.failure() and return cleanly, as this is an "acceptable" failure?
1200
+ job.logger.error(
1201
+ "Job %s is a singleton and already running.",
1202
+ job.job_model,
1203
+ extra={"object": job.job_model, "grouping": "initialization"},
1204
+ )
1205
+ raise RunJobTaskFailed(f"Job '{job.job_model}' is a singleton and already running.")
1206
+ cache_parameters = {
1207
+ "key": job.singleton_cache_key,
1208
+ "value": 1,
1209
+ "timeout": job.job_model.time_limit or settings.CELERY_TASK_TIME_LIMIT,
1210
+ }
1211
+ cache.set(**cache_parameters)
1212
+
1213
+ # Check for validity of the soft/hard time limits for the job.
1214
+ # TODO: this is a bit out of place?
1215
+ soft_time_limit = job.job_model.soft_time_limit or settings.CELERY_TASK_SOFT_TIME_LIMIT
1216
+ time_limit = job.job_model.time_limit or settings.CELERY_TASK_TIME_LIMIT
1217
+ if time_limit <= soft_time_limit:
1218
+ job.logger.warning(
1219
+ "The hard time limit of %s seconds is less than "
1220
+ "or equal to the soft time limit of %s seconds. "
1221
+ "This job will fail silently after %s seconds.",
1222
+ time_limit,
1223
+ soft_time_limit,
1224
+ time_limit,
1225
+ extra={"grouping": "initialization", "object": job.job_model},
1226
+ )
1227
+
1228
+ # Send notice that the job is running
1229
+ event_payload = {
1230
+ "job_result_id": request.id,
1231
+ "job_name": job.name, # TODO: should this be job.job_model.name instead? Possible breaking change
1232
+ "user_name": job_result.user.username,
1233
+ }
1234
+ if not job.job_model.has_sensitive_variables:
1235
+ event_payload["job_kwargs"] = kwargs
1236
+ publish_event(topic="nautobot.jobs.job.started", payload=event_payload)
1237
+ job.logger.info("Running job", extra={"grouping": "initialization", "object": job.job_model})
1238
+
1239
+ # Return the job, ready to run
1240
+ return job, event_payload
1241
+
1242
+
1243
+ def _cleanup_job(job, event_payload, status, kwargs):
1244
+ """Helper method to run_job task, handling cleanup after running a Job."""
1245
+ # Cleanup FileProxy objects
1246
+ file_fields = list(job._get_file_vars())
1247
+ file_ids = [kwargs[f] for f in file_fields if f in kwargs]
1248
+ if file_ids:
1249
+ job._delete_file_proxies(*file_ids)
1250
+
1251
+ if status == JobResultStatusChoices.STATUS_SUCCESS:
1252
+ job.logger.success("Job completed", extra={"grouping": "post_run"})
1253
+
1254
+ publish_event(topic="nautobot.jobs.job.completed", payload=event_payload)
1255
+
1256
+ cache.delete(job.singleton_cache_key)
1257
+
1258
+
1208
1259
  @nautobot_task(bind=True)
1209
1260
  def run_job(self, job_class_path, *args, **kwargs):
1210
1261
  """
@@ -1212,49 +1263,76 @@ def run_job(self, job_class_path, *args, **kwargs):
1212
1263
 
1213
1264
  This calls the following Job APIs in the following order:
1214
1265
 
1215
- - `__init__()`
1216
- - `before_start()`
1217
- - `__call__()` (which calls `run()`)
1218
- - If no exceptions have been raised, `on_success()`, else `on_failure()`
1219
- - `after_return()`
1266
+ - `Job.__init__()`
1267
+ - `Job.before_start(self.request.id, args, kwargs)`
1268
+ - `Job.__call__(*args, **kwargs)` (which calls `run(*args, **kwargs)`)
1269
+ - If no exceptions have been raised (and `Job.fail()` was not called):
1270
+ - `Job.on_success(result, self.request.id, args, kwargs)`
1271
+ - Else:
1272
+ - `Job.on_failure(result_or_exception, self.request.id, args, kwargs, einfo)`
1273
+ - `Job.after_return(status, result_or_exception, self.request.id, args, kwargs, einfo)`
1220
1274
 
1221
- Finally, it either returns the data returned from `run()` or re-raises any exception encountered.
1275
+ Finally, it either returns any data returned from `Job.run()` or re-raises any exception encountered.
1222
1276
  """
1223
- logger.debug("Running job %s", job_class_path)
1224
1277
 
1225
- job_class = get_job(job_class_path, reload=True)
1226
- if job_class is None:
1227
- raise KeyError(f"Job class not found for class path {job_class_path}")
1228
- job = job_class()
1229
- job.request = self.request
1230
- job_result = JobResult.objects.get(id=self.request.id)
1231
- payload = {
1232
- "job_result_id": self.request.id,
1233
- "job_name": job.name,
1234
- "user_name": job_result.user.username,
1235
- }
1236
- if not job.job_model.has_sensitive_variables:
1237
- payload["job_kwargs"] = kwargs
1278
+ job, event_payload = _prepare_job(job_class_path, self.request, kwargs)
1279
+
1280
+ result = None
1281
+ status = None
1238
1282
  try:
1239
- publish_event(topic="nautobot.jobs.job.started", payload=payload)
1240
- job.before_start(self.request.id, args, kwargs)
1241
- result = job(*args, **kwargs)
1242
- job.on_success(result, self.request.id, args, kwargs)
1243
- job.after_return(JobResultStatusChoices.STATUS_SUCCESS, result, self.request.id, args, kwargs, None)
1244
- payload["job_output"] = result
1245
- publish_event(topic="nautobot.jobs.job.completed", payload=payload)
1246
- return result
1283
+ before_start_result = job.before_start(self.request.id, args, kwargs)
1284
+ if not job._failed:
1285
+ # Call job(), which automatically calls job.run():
1286
+ result = job(*args, **kwargs)
1287
+ else:
1288
+ # don't run the job if before_start() reported a failure, and report the before_start() return value
1289
+ result = before_start_result
1290
+
1291
+ event_payload["job_output"] = result
1292
+ status = JobResultStatusChoices.STATUS_SUCCESS if not job._failed else JobResultStatusChoices.STATUS_FAILURE
1293
+
1294
+ if status == JobResultStatusChoices.STATUS_SUCCESS:
1295
+ job.on_success(result, self.request.id, args, kwargs)
1296
+ else:
1297
+ job.on_failure(result, self.request.id, args, kwargs, None)
1298
+
1299
+ job.after_return(status, result, self.request.id, args, kwargs, None)
1300
+
1301
+ if status == JobResultStatusChoices.STATUS_SUCCESS:
1302
+ return result
1303
+
1304
+ # Report a failure, but with a result rather than an exception and einfo:
1305
+ self.update_state(
1306
+ state=status,
1307
+ meta=result,
1308
+ )
1309
+ # If we return a result, Celery automatically applies STATUS_SUCCESS.
1310
+ # If we raise an exception *other than* `Ignore` or `Reject`, Celery automatically applies STATUS_FAILURE.
1311
+ # We don't want to overwrite the manual state update that we did above, so:
1312
+ raise Ignore()
1313
+
1314
+ except Reject:
1315
+ status = status or JobResultStatusChoices.STATUS_REJECTED
1316
+ raise
1317
+
1318
+ except Ignore:
1319
+ status = status or JobResultStatusChoices.STATUS_IGNORED
1320
+ raise
1321
+
1247
1322
  except Exception as exc:
1323
+ status = JobResultStatusChoices.STATUS_FAILURE
1248
1324
  einfo = ExceptionInfo(sys.exc_info())
1249
1325
  job.on_failure(exc, self.request.id, args, kwargs, einfo)
1250
1326
  job.after_return(JobResultStatusChoices.STATUS_FAILURE, exc, self.request.id, args, kwargs, einfo)
1251
- payload["einfo"] = {
1327
+ event_payload["einfo"] = {
1252
1328
  "exc_type": type(exc).__name__,
1253
1329
  "exc_message": sanitize(str(exc)),
1254
1330
  }
1255
- publish_event(topic="nautobot.jobs.job.completed", payload=payload)
1256
1331
  raise
1257
1332
 
1333
+ finally:
1334
+ _cleanup_job(job, event_payload, status, kwargs)
1335
+
1258
1336
 
1259
1337
  def enqueue_job_hooks(object_change, may_reload_jobs=True, jobhook_queryset=None):
1260
1338
  """
@@ -47,12 +47,21 @@ def report_job_status(command, job_result):
47
47
  logs = JobLogEntry.objects.filter(job_result__pk=job_result.pk, grouping=group)
48
48
  debug_count = logs.filter(log_level=LogLevelChoices.LOG_DEBUG).count()
49
49
  info_count = logs.filter(log_level=LogLevelChoices.LOG_INFO).count()
50
+ success_count = logs.filter(log_level=LogLevelChoices.LOG_SUCCESS).count()
50
51
  warning_count = logs.filter(log_level=LogLevelChoices.LOG_WARNING).count()
52
+ failure_count = logs.filter(log_level=LogLevelChoices.LOG_FAILURE).count()
51
53
  error_count = logs.filter(log_level=LogLevelChoices.LOG_ERROR).count()
52
54
  critical_count = logs.filter(log_level=LogLevelChoices.LOG_CRITICAL).count()
53
55
 
54
56
  command.stdout.write(
55
- f"\t{group}: {debug_count} debug, {info_count} info, {warning_count} warning, {error_count} error, {critical_count} critical"
57
+ f"\t{group}: "
58
+ f"{debug_count} debug, "
59
+ f"{info_count} info, "
60
+ f"{success_count} success, "
61
+ f"{warning_count} warning, "
62
+ f"{failure_count} failure, "
63
+ f"{error_count} error, "
64
+ f"{critical_count} critical"
56
65
  )
57
66
 
58
67
  for log_entry in logs:
@@ -63,7 +72,7 @@ def report_job_status(command, job_result):
63
72
  status = status
64
73
  elif status == "warning":
65
74
  status = command.style.WARNING(status)
66
- elif status == "failure":
75
+ elif status in ["failure", "error", "critical"]:
67
76
  status = command.style.NOTICE(status)
68
77
 
69
78
  if log_entry.log_object:
@@ -73,6 +82,8 @@ def report_job_status(command, job_result):
73
82
 
74
83
  if job_result.result:
75
84
  command.stdout.write(str(job_result.result))
85
+ if job_result.traceback:
86
+ command.stdout.write(command.style.ERROR(job_result.traceback))
76
87
 
77
88
  if job_result.status == JobResultStatusChoices.STATUS_FAILURE:
78
89
  status = command.style.ERROR("FAILURE")
@@ -220,6 +220,8 @@ class GitRepository(PrimaryModel):
220
220
  Returns:
221
221
  Returns the absolute path of the cloned repo if clone was successful, otherwise returns None.
222
222
  """
223
+ from nautobot.extras.datasources import get_repo_access_url
224
+
223
225
  if branch and head:
224
226
  raise ValueError("Cannot specify both branch and head")
225
227
 
@@ -233,7 +235,8 @@ class GitRepository(PrimaryModel):
233
235
  branch = self.branch
234
236
 
235
237
  try:
236
- repo_helper = GitRepo(path_name, self.remote_url, depth=depth, branch=branch)
238
+ remote_url = get_repo_access_url(self)
239
+ repo_helper = GitRepo(path_name, remote_url, depth=depth, branch=branch)
237
240
  if head:
238
241
  repo_helper.checkout(branch, head)
239
242
  except Exception as e:
@@ -897,25 +897,28 @@ class JobResult(BaseModel, CustomFieldModel):
897
897
  # Cancel the scheduled SIGALRM if it hasn't fired already
898
898
  signal.alarm(0)
899
899
 
900
- # copy fields from eager result to job result
901
900
  job_result.refresh_from_db()
902
- # Emulate prepare_exception() behavior
903
- if isinstance(eager_result.result, Exception):
904
- job_result.result = {
905
- "exc_type": type(eager_result.result).__name__,
906
- "exc_message": sanitize(str(eager_result.result)),
907
- }
908
- else:
909
- if eager_result.result is not None:
901
+ # copy from eager result to job result if and only if the job result isn't already in a proper state.
902
+ if JobResultStatusChoices.precedence(job_result.status) > JobResultStatusChoices.precedence(
903
+ eager_result.status
904
+ ):
905
+ if eager_result.status in JobResultStatusChoices.EXCEPTION_STATES and isinstance(
906
+ eager_result.result, Exception
907
+ ):
908
+ job_result.result = {
909
+ "exc_type": type(eager_result.result).__name__,
910
+ "exc_message": sanitize(str(eager_result.result)),
911
+ }
912
+ elif eager_result.result is not None:
910
913
  job_result.result = sanitize(eager_result.result)
911
- else:
912
- job_result.result = None
913
- job_result.status = eager_result.status
914
- if eager_result.traceback is not None:
915
- job_result.traceback = sanitize(eager_result.traceback)
916
- else:
917
- job_result.traceback = None
918
- job_result.date_done = timezone.now()
914
+ job_result.status = eager_result.status
915
+ if (
916
+ eager_result.status in JobResultStatusChoices.EXCEPTION_STATES
917
+ and eager_result.traceback is not None
918
+ ):
919
+ job_result.traceback = sanitize(eager_result.traceback)
920
+ if not job_result.date_done:
921
+ job_result.date_done = timezone.now()
919
922
  job_result.save()
920
923
  else:
921
924
  # Jobs queued inside of a transaction need to run after the transaction completes and the JobResult is saved to the database
@@ -94,6 +94,24 @@
94
94
  "availability": "Open Source"
95
95
  "icon": >-
96
96
  data:image/png;base64,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
97
+ - "name": "DNS Models"
98
+ "use_cases":
99
+ - "DNS"
100
+ "requires": []
101
+ "docs": "https://docs.nautobot.com/projects/dns-models/en/latest/"
102
+ "headline": "Model DNS Zones and their associated records in a vendor-neutral manner."
103
+ "description": >-
104
+ DNS Models allows users to document and understand the network by modeling their DNS data. DNS Records can then
105
+ be directly related to data about the network.
106
+ "package_name": "nautobot_dns_models"
107
+ "author": "Network to Code (NTC)"
108
+ "display":
109
+ "cloud": true
110
+ "oss": true
111
+ "enterprise": true
112
+ "availability": "Open Source"
113
+ "icon": >-
114
+ data:image/png;base64,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
97
115
  - "name": "Data Validation Engine"
98
116
  "use_cases":
99
117
  - "Data Management"
nautobot/extras/tables.py CHANGED
@@ -112,6 +112,27 @@ JOB_BUTTONS = """
112
112
  <a href="{% url 'extras:jobresult_list' %}?job_model={{ record.name | urlencode }}" class="btn btn-default btn-xs" title="Job Results"><i class="mdi mdi-format-list-bulleted" aria-hidden="true"></i></a>
113
113
  """
114
114
 
115
+ JOB_RESULT_BUTTONS = """
116
+ {% load helpers %}
117
+ {% if perms.extras.run_job %}
118
+ {% if record.job_model and record.task_kwargs %}
119
+ <a href="{% url 'extras:job_run' pk=record.job_model.pk %}?kwargs_from_job_result={{ record.pk }}"
120
+ class="btn btn-xs btn-success" title="Re-run job with same arguments.">
121
+ <i class="mdi mdi-repeat"></i>
122
+ </a>
123
+ {% elif record.job_model is not None %}
124
+ <a href="{% url 'extras:job_run' pk=record.job_model.pk %}" class="btn btn-primary btn-xs"
125
+ title="Run job">
126
+ <i class="mdi mdi-play"></i>
127
+ </a>
128
+ {% else %}
129
+ <a href="#" class="btn btn-xs btn-default disabled" title="Job is not available, cannot be re-run">
130
+ <i class="mdi mdi-repeat-off"></i>
131
+ </a>
132
+ {% endif %}
133
+ {% endif %}
134
+ """
135
+
115
136
  SCHEDULED_JOB_BUTTONS = """
116
137
  <a href="{% url 'extras:jobresult_list' %}?scheduled_job={{ record.name | urlencode }}" class="btn btn-default btn-xs" title="Job Results"><i class="mdi mdi-format-list-bulleted" aria-hidden="true"></i></a>
117
138
  """
@@ -675,7 +696,7 @@ def log_object_link(value, record):
675
696
 
676
697
 
677
698
  def log_entry_color_css(record):
678
- if record.log_level.lower() in ("error", "critical"):
699
+ if record.log_level.lower() in ("failure", "error", "critical"):
679
700
  return "danger"
680
701
  return record.log_level.lower()
681
702
 
@@ -811,7 +832,7 @@ class JobLogEntryTable(BaseTable):
811
832
  def render_log_level(self, value):
812
833
  log_level = value.lower()
813
834
  # The css is label-danger for failure items.
814
- if log_level in ["error", "critical"]:
835
+ if log_level in ["failure", "error", "critical"]:
815
836
  log_level = "danger"
816
837
  elif log_level == "debug":
817
838
  log_level = "default"
@@ -877,32 +898,7 @@ class JobResultTable(BaseTable):
877
898
  linkify=True,
878
899
  verbose_name="Scheduled Job",
879
900
  )
880
- actions = tables.TemplateColumn(
881
- template_code="""
882
- {% load helpers %}
883
- {% if perms.extras.run_job %}
884
- {% if record.job_model and record.task_kwargs %}
885
- <a href="{% url 'extras:job_run' pk=record.job_model.pk %}?kwargs_from_job_result={{ record.pk }}"
886
- class="btn btn-xs btn-success" title="Re-run job with same arguments.">
887
- <i class="mdi mdi-repeat"></i>
888
- </a>
889
- {% elif record.job_model is not None %}
890
- <a href="{% url 'extras:job_run' pk=record.job_model.pk %}" class="btn btn-primary btn-xs"
891
- title="Run job">
892
- <i class="mdi mdi-play"></i>
893
- </a>
894
- {% else %}
895
- <a href="#" class="btn btn-xs btn-default disabled" title="Job is not available, cannot be re-run">
896
- <i class="mdi mdi-repeat-off"></i>
897
- </a>
898
- {% endif %}
899
- {% endif %}
900
- <a href="{% url 'extras:jobresult_delete' pk=record.pk %}" class="btn btn-xs btn-danger"
901
- title="Delete this job result.">
902
- <i class="mdi mdi-trash-can-outline"></i>
903
- </a>
904
- """
905
- )
901
+ actions = ButtonsColumn(JobResult, buttons=("delete",), prepend_template=JOB_RESULT_BUTTONS)
906
902
 
907
903
  def render_summary(self, record):
908
904
  """
@@ -1067,14 +1063,13 @@ class ObjectMetadataTable(BaseTable):
1067
1063
 
1068
1064
  class NoteTable(BaseTable):
1069
1065
  actions = ButtonsColumn(Note)
1070
- created = tables.LinkColumn()
1071
- note = tables.Column(
1072
- attrs={"td": {"class": "rendered-markdown"}},
1073
- )
1066
+ created = tables.DateTimeColumn(linkify=True)
1067
+ last_updated = tables.DateTimeColumn()
1068
+ note = tables.Column()
1074
1069
 
1075
1070
  class Meta(BaseTable.Meta):
1076
1071
  model = Note
1077
- fields = ("created", "note", "user_name")
1072
+ fields = ("created", "last_updated", "note", "user_name")
1078
1073
 
1079
1074
  def render_note(self, value):
1080
1075
  return render_markdown(value)
@@ -1094,7 +1089,7 @@ class ScheduledJobTable(BaseTable):
1094
1089
  last_run_at = tables.DateTimeColumn(verbose_name="Most Recent Run", format=settings.SHORT_DATETIME_FORMAT)
1095
1090
  crontab = tables.Column()
1096
1091
  total_run_count = tables.Column(verbose_name="Total Run Count")
1097
- actions = ButtonsColumn(ScheduledJob, buttons=("delete"), prepend_template=SCHEDULED_JOB_BUTTONS)
1092
+ actions = ButtonsColumn(ScheduledJob, buttons=("delete",), prepend_template=SCHEDULED_JOB_BUTTONS)
1098
1093
 
1099
1094
  class Meta(BaseTable.Meta):
1100
1095
  model = ScheduledJob
@@ -21,7 +21,7 @@
21
21
  <br />
22
22
  <small>
23
23
  <span class="text-muted">{{ change.user_name }} -</span>
24
- <a href="{{ change.get_absolute_url }}" class="text-muted">{{ change.time|date:'SHORT_DATETIME_FORMAT' }}</a>
24
+ <a href="{{ change.get_absolute_url }}" class="text-muted">{{ change.time|date:settings.SHORT_DATETIME_FORMAT }}</a>
25
25
  </small>
26
26
  </div>
27
27
  {% endwith %}
@@ -7,7 +7,7 @@
7
7
  <span class="pull-right" title="{{ result.date_created }}">{% include 'extras/inc/job_label.html' %}</span>
8
8
  <br>
9
9
  <small>
10
- <span class="text-muted">{{ result.user }} - {{ result.date_done|date:'SHORT_DATETIME_FORMAT' }}</span>
10
+ <span class="text-muted">{{ result.user }} - {{ result.date_done|date:settings.SHORT_DATETIME_FORMAT }}</span>
11
11
  </small>
12
12
  </div>
13
13
  {% if forloop.last %}