Skip to content

Commit 875a641

Browse files
Closes #19589: Background job for bulk operations (#19804)
* Initial work on #19589 * Add tooling for handling background requests * UI notification should link to enqueued job * Use an informative name for the job * Disable background jobs for file uploads
1 parent 6022433 commit 875a641

File tree

8 files changed

+149
-22
lines changed

8 files changed

+149
-22
lines changed

netbox/core/models/jobs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ class Meta:
116116
verbose_name_plural = _('jobs')
117117

118118
def __str__(self):
119-
return str(self.job_id)
119+
return self.name
120120

121121
def get_absolute_url(self):
122122
# TODO: Employ dynamic registration

netbox/netbox/jobs.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@
88
from rq.timeouts import JobTimeoutException
99

1010
from core.choices import JobStatusChoices
11+
from core.events import JOB_COMPLETED, JOB_FAILED
1112
from core.models import Job, ObjectType
13+
from extras.models import Notification
1214
from netbox.constants import ADVISORY_LOCK_KEYS
1315
from netbox.registry import registry
16+
from utilities.request import apply_request_processors
1417

1518
__all__ = (
19+
'AsyncViewJob',
1620
'JobRunner',
1721
'system_job',
1822
)
@@ -154,3 +158,35 @@ class scheduled for `instance`, the existing job will be updated if necessary. T
154158
job.delete()
155159

156160
return cls.enqueue(instance=instance, schedule_at=schedule_at, interval=interval, *args, **kwargs)
161+
162+
163+
class AsyncViewJob(JobRunner):
164+
"""
165+
Execute a view as a background job.
166+
"""
167+
class Meta:
168+
name = 'Async View'
169+
170+
def run(self, view_cls, request, **kwargs):
171+
view = view_cls.as_view()
172+
173+
# Apply all registered request processors (e.g. event_tracking)
174+
with apply_request_processors(request):
175+
data = view(request)
176+
177+
self.job.data = {
178+
'log': data.log,
179+
'errors': data.errors,
180+
}
181+
182+
# Notify the user
183+
notification = Notification(
184+
user=request.user,
185+
object=self.job,
186+
event_type=JOB_COMPLETED if not data.errors else JOB_FAILED,
187+
)
188+
notification.save()
189+
190+
# TODO: Waiting on fix for bug #19806
191+
# if errors:
192+
# raise JobFailed()

netbox/netbox/middleware.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
from contextlib import ExitStack
2-
31
import logging
42
import uuid
5-
import warnings
63

74
from django.conf import settings
85
from django.contrib import auth, messages
@@ -13,10 +10,10 @@
1310
from django.http import Http404, HttpResponseRedirect
1411

1512
from netbox.config import clear_config, get_config
16-
from netbox.registry import registry
1713
from netbox.views import handler_500
1814
from utilities.api import is_api_request
1915
from utilities.error_handlers import handle_rest_api_exception
16+
from utilities.request import apply_request_processors
2017

2118
__all__ = (
2219
'CoreMiddleware',
@@ -36,12 +33,7 @@ def __call__(self, request):
3633
request.id = uuid.uuid4()
3734

3835
# Apply all registered request processors
39-
with ExitStack() as stack:
40-
for request_processor in registry['request_processors']:
41-
try:
42-
stack.enter_context(request_processor(request))
43-
except Exception as e:
44-
warnings.warn(f'Failed to initialize request processor {request_processor}: {e}')
36+
with apply_request_processors(request):
4537
response = self.get_response(request)
4638

4739
# Check if language cookie should be renewed

netbox/netbox/views/generic/bulk_views.py

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
2929
from utilities.forms.bulk_import import BulkImportForm
3030
from utilities.htmx import htmx_partial
31+
from utilities.jobs import AsyncJobData, is_background_request, process_request_as_job
3132
from utilities.permissions import get_permission_for_model
3233
from utilities.query import reapply_model_ordering
3334
from utilities.request import safe_for_redirect
@@ -503,25 +504,32 @@ def post(self, request):
503504

504505
if form.is_valid():
505506
logger.debug("Import form validation was successful")
507+
redirect_url = reverse(get_viewname(model, action='list'))
508+
new_objects = []
509+
510+
# If indicated, defer this request to a background job & redirect the user
511+
if form.cleaned_data['background_job']:
512+
job_name = _('Bulk import {count} {object_type}').format(
513+
count=len(form.cleaned_data['data']),
514+
object_type=model._meta.verbose_name_plural,
515+
)
516+
if job := process_request_as_job(self.__class__, request, name=job_name):
517+
msg = _('Created background job {job.pk}: <a href="{url}">{job.name}</a>').format(
518+
url=job.get_absolute_url(),
519+
job=job
520+
)
521+
messages.info(request, mark_safe(msg))
522+
return redirect(redirect_url)
506523

507524
try:
508525
# Iterate through data and bind each record to a new model form instance.
509526
with transaction.atomic(using=router.db_for_write(model)):
510-
new_objs = self.create_and_update_objects(form, request)
527+
new_objects = self.create_and_update_objects(form, request)
511528

512529
# Enforce object-level permissions
513-
if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
530+
if self.queryset.filter(pk__in=[obj.pk for obj in new_objects]).count() != len(new_objects):
514531
raise PermissionsViolation
515532

516-
if new_objs:
517-
msg = f"Imported {len(new_objs)} {model._meta.verbose_name_plural}"
518-
logger.info(msg)
519-
messages.success(request, msg)
520-
521-
view_name = get_viewname(model, action='list')
522-
results_url = f"{reverse(view_name)}?modified_by_request={request.id}"
523-
return redirect(results_url)
524-
525533
except (AbortTransaction, ValidationError):
526534
clear_events.send(sender=self)
527535

@@ -530,6 +538,25 @@ def post(self, request):
530538
form.add_error(None, e.message)
531539
clear_events.send(sender=self)
532540

541+
# If this request was executed via a background job, return the raw data for logging
542+
if is_background_request(request):
543+
return AsyncJobData(
544+
log=[
545+
_('Created {object}').format(object=str(obj))
546+
for obj in new_objects
547+
],
548+
errors=form.errors
549+
)
550+
551+
if new_objects:
552+
msg = _("Imported {count} {object_type}").format(
553+
count=len(new_objects),
554+
object_type=model._meta.verbose_name_plural
555+
)
556+
logger.info(msg)
557+
messages.success(request, msg)
558+
return redirect(f"{redirect_url}?modified_by_request={request.id}")
559+
533560
else:
534561
logger.debug("Form validation failed")
535562

netbox/templates/generic/bulk_import.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
{% render_field form.data %}
5151
{% render_field form.format %}
5252
{% render_field form.csv_delimiter %}
53+
{% render_field form.background_job %}
5354
<div class="form-group">
5455
<div class="col col-md-12 text-end">
5556
{% if return_url %}
@@ -94,6 +95,7 @@
9495
{% render_field form.data_file %}
9596
{% render_field form.format %}
9697
{% render_field form.csv_delimiter %}
98+
{% render_field form.background_job %}
9799
<div class="form-group">
98100
<div class="col col-md-12 text-end">
99101
{% if return_url %}

netbox/utilities/forms/bulk_import.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ class BulkImportForm(SyncedDataMixin, forms.Form):
3737
help_text=_("The character which delimits CSV fields. Applies only to CSV format."),
3838
required=False
3939
)
40+
background_job = forms.BooleanField(
41+
label=_('Background job'),
42+
help_text=_("Enqueue a background job to complete the bulk import/update."),
43+
required=False,
44+
)
4045

4146
data_field = 'data'
4247

netbox/utilities/jobs.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from dataclasses import dataclass
2+
from typing import List
3+
4+
from netbox.jobs import AsyncViewJob
5+
from utilities.request import copy_safe_request
6+
7+
__all__ = (
8+
'AsyncJobData',
9+
'is_background_request',
10+
'process_request_as_job',
11+
)
12+
13+
14+
@dataclass
15+
class AsyncJobData:
16+
log: List[str]
17+
errors: List[str]
18+
19+
20+
def is_background_request(request):
21+
"""
22+
Return True if the request is being processed as a background job.
23+
"""
24+
return getattr(request, '_background', False)
25+
26+
27+
def process_request_as_job(view, request, name=None):
28+
"""
29+
Process a request using a view as a background job.
30+
"""
31+
32+
# Check that the request that is not already being processed as a background job (would be a loop)
33+
if is_background_request(request):
34+
return
35+
36+
# Create a serializable copy of the original request
37+
request_copy = copy_safe_request(request)
38+
request_copy._background = True
39+
40+
# Enqueue a job to perform the work in the background
41+
return AsyncViewJob.enqueue(
42+
name=name,
43+
user=request.user,
44+
view_cls=view,
45+
request=request_copy,
46+
)

netbox/utilities/request.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
import warnings
2+
from contextlib import ExitStack, contextmanager
13
from urllib.parse import urlparse
24

35
from django.utils.http import url_has_allowed_host_and_scheme
46
from django.utils.translation import gettext_lazy as _
57
from netaddr import AddrFormatError, IPAddress
68

9+
from netbox.registry import registry
710
from .constants import HTTP_REQUEST_META_SAFE_COPY
811

912
__all__ = (
1013
'NetBoxFakeRequest',
14+
'apply_request_processors',
1115
'copy_safe_request',
1216
'get_client_ip',
1317
'safe_for_redirect',
@@ -48,6 +52,7 @@ def copy_safe_request(request):
4852
'GET': request.GET,
4953
'FILES': request.FILES,
5054
'user': request.user,
55+
'method': request.method,
5156
'path': request.path,
5257
'id': getattr(request, 'id', None), # UUID assigned by middleware
5358
})
@@ -87,3 +92,17 @@ def safe_for_redirect(url):
8792
Returns True if the given URL is safe to use as an HTTP redirect; otherwise returns False.
8893
"""
8994
return url_has_allowed_host_and_scheme(url, allowed_hosts=None)
95+
96+
97+
@contextmanager
98+
def apply_request_processors(request):
99+
"""
100+
A context manager with applies all registered request processors (such as event_tracking).
101+
"""
102+
with ExitStack() as stack:
103+
for request_processor in registry['request_processors']:
104+
try:
105+
stack.enter_context(request_processor(request))
106+
except Exception as e:
107+
warnings.warn(f'Failed to initialize request processor {request_processor.__name__}: {e}')
108+
yield

0 commit comments

Comments
 (0)