From bcb0c6ccbcec39c887495ad92de4f3f536b86da8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 2 Jul 2025 15:43:19 -0400 Subject: [PATCH] Add tooling for handling background requests --- netbox/netbox/jobs.py | 15 ++++---- netbox/netbox/views/generic/bulk_views.py | 47 +++++++++-------------- netbox/utilities/jobs.py | 45 ++++++++++++++++++++++ 3 files changed, 72 insertions(+), 35 deletions(-) create mode 100644 netbox/utilities/jobs.py diff --git a/netbox/netbox/jobs.py b/netbox/netbox/jobs.py index 73cc2f4e8..b7a6a6db1 100644 --- a/netbox/netbox/jobs.py +++ b/netbox/netbox/jobs.py @@ -172,20 +172,21 @@ class AsyncViewJob(JobRunner): # Apply all registered request processors (e.g. event_tracking) with apply_request_processors(request): - result, errors = view(request) + data = view(request) self.job.data = { - 'result': result, - 'errors': errors, + 'log': data.log, + 'errors': data.errors, } - # TODO: Figure out how to mark a job as "failed" - # if errors: - # self.job.terminate(status=JobStatusChoices.STATUS_FAILED, error=errors[0]) # Notify the user notification = Notification( user=request.user, object=self.job, - event_type=JOB_COMPLETED if not errors else JOB_FAILED, + event_type=JOB_COMPLETED if not data.errors else JOB_FAILED, ) notification.save() + + # TODO: Waiting on fix for bug #19806 + # if errors: + # raise JobFailed() diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 075599693..23577e7fb 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -22,16 +22,16 @@ from core.models import ObjectType from core.signals import clear_events from extras.choices import CustomFieldUIEditableChoices from extras.models import CustomField, ExportTemplate -from netbox.jobs import AsyncViewJob from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields from utilities.forms.bulk_import import BulkImportForm from utilities.htmx import htmx_partial +from utilities.jobs import AsyncJobData, is_background_request, process_request_as_job from utilities.permissions import get_permission_for_model from utilities.query import reapply_model_ordering -from utilities.request import copy_safe_request, safe_for_redirect +from utilities.request import safe_for_redirect from utilities.tables import get_table_configs from utilities.views import GetReturnURLMixin, get_viewname from .base import BaseMultiObjectView @@ -504,25 +504,11 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): redirect_url = reverse(get_viewname(model, action='list')) new_objects = [] - # Defer the request to a background job? - if form.cleaned_data['background_job'] and not getattr(request, '_background', False): - - # Create a serializable copy of the original request - request_copy = copy_safe_request(request) - request_copy._background = True - - # Enqueue a job to perform the work in the background - job = AsyncViewJob.enqueue( - user=request.user, - view_cls=self.__class__, - request=request_copy, - ) - msg = _("Background job enqueued: {job}").format(job=job.pk) - logger.info(msg) - messages.info(request, msg) - - # Redirect to the model's list view - return redirect(redirect_url) + # If indicated, defer this request to a background job & redirect the user + if form.cleaned_data['background_job']: + if job := process_request_as_job(self.__class__, request): + messages.info(request, _("Background job enqueued: {job}").format(job=job.pk)) + return redirect(redirect_url) try: # Iterate through data and bind each record to a new model form instance. @@ -542,15 +528,20 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): clear_events.send(sender=self) # If this request was executed via a background job, return the raw data for logging - if getattr(request, '_background', False): - result = [ - _('Created {object}').format(object=str(obj)) - for obj in new_objects - ] - return result, form.errors + if is_background_request(request): + return AsyncJobData( + log=[ + _('Created {object}').format(object=str(obj)) + for obj in new_objects + ], + errors=form.errors + ) if new_objects: - msg = f"Imported {len(new_objects)} {model._meta.verbose_name_plural}" + msg = _("Imported {count} {object_type}").format( + count=len(new_objects), + object_type=model._meta.verbose_name_plural + ) logger.info(msg) messages.success(request, msg) return redirect(f"{redirect_url}?modified_by_request={request.id}") diff --git a/netbox/utilities/jobs.py b/netbox/utilities/jobs.py new file mode 100644 index 000000000..d7ace484c --- /dev/null +++ b/netbox/utilities/jobs.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from typing import List + +from netbox.jobs import AsyncViewJob +from utilities.request import copy_safe_request + +__all__ = ( + 'AsyncJobData', + 'is_background_request', + 'process_request_as_job', +) + + +@dataclass +class AsyncJobData: + log: List[str] + errors: List[str] + + +def is_background_request(request): + """ + Return True if the request is being processed as a background job. + """ + return getattr(request, '_background', False) + + +def process_request_as_job(view, request): + """ + Process a request using a view as a background job. + """ + + # Check that the request that is not already being processed as a background job (would be a loop) + if is_background_request(request): + return + + # Create a serializable copy of the original request + request_copy = copy_safe_request(request) + request_copy._background = True + + # Enqueue a job to perform the work in the background + return AsyncViewJob.enqueue( + user=request.user, + view_cls=view, + request=request_copy, + )