Closes #19891: Bulk operation jobs (#19897)

* Add background_job toggle to BulkEditForm

* Account for bug fix in v4.3.4

* Enable background jobs for bulk edit & bulk delete

* Move background_job field to a mixin

* Cosmetic improvements

* Misc cleanup

* Fix BackgroundJobMixin
This commit is contained in:
Jeremy Stretch 2025-07-18 09:24:38 -04:00 committed by GitHub
parent 7f2b744a53
commit cebc56e5cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 121 additions and 31 deletions

View File

@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType from core.models import ObjectType
from extras.choices import * from extras.choices import *
from extras.models import CustomField, Tag from extras.models import CustomField, Tag
from utilities.forms import CSVModelForm from utilities.forms import BulkEditForm, CSVModelForm
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.mixins import CheckLastUpdatedMixin from utilities.forms.mixins import CheckLastUpdatedMixin
from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
@ -100,7 +100,7 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
return customfield.to_form_field(for_csv_import=True) return customfield.to_form_field(for_csv_import=True)
class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form): class NetBoxModelBulkEditForm(CustomFieldsMixin, BulkEditForm):
""" """
Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom
fields and adding/removing tags. fields and adding/removing tags.
@ -108,9 +108,8 @@ class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form):
Attributes: Attributes:
fieldsets: An iterable of two-tuples which define a heading and field set to display per section of fieldsets: An iterable of two-tuples which define a heading and field set to display per section of
the rendered form (optional). If not defined, the all fields will be rendered as a single section. the rendered form (optional). If not defined, the all fields will be rendered as a single section.
nullable_fields: A list of field names indicating which fields support being set to null/empty
""" """
nullable_fields = () fieldsets = None
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=None, # Set from self.model on init queryset=None, # Set from self.model on init

View File

@ -212,6 +212,5 @@ class AsyncViewJob(JobRunner):
) )
notification.save() notification.save()
# TODO: Waiting on fix for bug #19806 if data.errors:
# if errors: raise JobFailed()
# raise JobFailed()

View File

@ -27,6 +27,7 @@ from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViol
from utilities.export import TableExport from utilities.export import TableExport
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
from utilities.forms.bulk_import import BulkImportForm from utilities.forms.bulk_import import BulkImportForm
from utilities.forms.mixins import BackgroundJobMixin
from utilities.htmx import htmx_partial from utilities.htmx import htmx_partial
from utilities.jobs import AsyncJobData, is_background_request, process_request_as_job from utilities.jobs import AsyncJobData, is_background_request, process_request_as_job
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
@ -513,12 +514,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
count=len(form.cleaned_data['data']), count=len(form.cleaned_data['data']),
object_type=model._meta.verbose_name_plural, object_type=model._meta.verbose_name_plural,
) )
if job := process_request_as_job(self.__class__, request, name=job_name): if process_request_as_job(self.__class__, request, name=job_name):
msg = _('Created background job {job.pk}: <a href="{url}">{job.name}</a>').format(
url=job.get_absolute_url(),
job=job
)
messages.info(request, mark_safe(msg))
return redirect(redirect_url) return redirect(redirect_url)
try: try:
@ -712,6 +708,16 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
if '_apply' in request.POST: if '_apply' in request.POST:
if form.is_valid(): if form.is_valid():
logger.debug("Form validation was successful") logger.debug("Form validation was successful")
# If indicated, defer this request to a background job & redirect the user
if form.cleaned_data['background_job']:
job_name = _('Bulk edit {count} {object_type}').format(
count=len(form.cleaned_data['pk']),
object_type=model._meta.verbose_name_plural,
)
if process_request_as_job(self.__class__, request, name=job_name):
return redirect(self.get_return_url(request))
try: try:
with transaction.atomic(using=router.db_for_write(model)): with transaction.atomic(using=router.db_for_write(model)):
updated_objects = self._update_objects(form, request) updated_objects = self._update_objects(form, request)
@ -721,6 +727,16 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
if object_count != len(updated_objects): if object_count != len(updated_objects):
raise PermissionsViolation raise PermissionsViolation
# If this request was executed via a background job, return the raw data for logging
if is_background_request(request):
return AsyncJobData(
log=[
_('Updated {object}').format(object=str(obj))
for obj in updated_objects
],
errors=form.errors
)
if updated_objects: if updated_objects:
msg = f'Updated {len(updated_objects)} {model._meta.verbose_name_plural}' msg = f'Updated {len(updated_objects)} {model._meta.verbose_name_plural}'
logger.info(msg) logger.info(msg)
@ -876,7 +892,7 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
""" """
Provide a standard bulk delete form if none has been specified for the view Provide a standard bulk delete form if none has been specified for the view
""" """
class BulkDeleteForm(ConfirmationForm): class BulkDeleteForm(BackgroundJobMixin, ConfirmationForm):
pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
return BulkDeleteForm return BulkDeleteForm
@ -908,6 +924,15 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
if form.is_valid(): if form.is_valid():
logger.debug("Form validation was successful") logger.debug("Form validation was successful")
# If indicated, defer this request to a background job & redirect the user
if form.cleaned_data['background_job']:
job_name = _('Bulk delete {count} {object_type}').format(
count=len(form.cleaned_data['pk']),
object_type=model._meta.verbose_name_plural,
)
if process_request_as_job(self.__class__, request, name=job_name):
return redirect(self.get_return_url(request))
# Delete objects # Delete objects
queryset = self.queryset.filter(pk__in=pk_list) queryset = self.queryset.filter(pk__in=pk_list)
deleted_count = queryset.count() deleted_count = queryset.count()
@ -929,6 +954,16 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
messages.error(request, mark_safe(e.message)) messages.error(request, mark_safe(e.message))
return redirect(self.get_return_url(request)) return redirect(self.get_return_url(request))
# If this request was executed via a background job, return the raw data for logging
if is_background_request(request):
return AsyncJobData(
log=[
_('Deleted {object}').format(object=str(obj))
for obj in queryset
],
errors=form.errors
)
msg = _("Deleted {count} {object_type}").format( msg = _("Deleted {count} {object_type}").format(
count=deleted_count, count=deleted_count,
object_type=model._meta.verbose_name_plural object_type=model._meta.verbose_name_plural

View File

@ -1,7 +1,8 @@
{% extends 'generic/_base.html' %} {% extends 'generic/_base.html' %}
{% load form_helpers %}
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %}
{% load i18n %} {% load i18n %}
{% load render_table from django_tables2 %}
{% comment %} {% comment %}
Blocks: Blocks:
@ -58,13 +59,23 @@ Context:
<div class="row mt-3"> <div class="row mt-3">
<form action="" method="post"> <form action="" method="post">
{% csrf_token %} {% csrf_token %}
{# Form fields #}
{% for field in form.hidden_fields %} {% for field in form.hidden_fields %}
{{ field }} {{ field }}
{% endfor %} {% endfor %}
{# Meta fields #}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
{% render_field form.background_job %}
</div>
{# Form buttons #}
<div class="text-end"> <div class="text-end">
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a> <a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
<button type="submit" name="_confirm" class="btn btn-danger">{% trans "Delete" %} {{ table.rows|length }} {{ model|meta:"verbose_name_plural" }}</button> <button type="submit" name="_confirm" class="btn btn-danger">{% trans "Delete" %} {{ table.rows|length }} {{ model|meta:"verbose_name_plural" }}</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>

View File

@ -1,8 +1,8 @@
{% extends 'generic/_base.html' %} {% extends 'generic/_base.html' %}
{% load helpers %}
{% load form_helpers %} {% load form_helpers %}
{% load render_table from django_tables2 %} {% load helpers %}
{% load i18n %} {% load i18n %}
{% load render_table from django_tables2 %}
{% comment %} {% comment %}
Blocks: Blocks:
@ -102,6 +102,11 @@ Context:
{% endif %} {% endif %}
{# Meta fields #}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
{% render_field form.background_job %}
</div>
<div class="btn-float-group-right"> <div class="btn-float-group-right">
<a href="{{ return_url }}" class="btn btn-outline-secondary btn-float">{% trans "Cancel" %}</a> <a href="{{ return_url }}" class="btn btn-outline-secondary btn-float">{% trans "Cancel" %}</a>
<button type="submit" name="_apply" class="btn btn-primary">{% trans "Apply" %}</button> <button type="submit" name="_apply" class="btn btn-primary">{% trans "Apply" %}</button>

View File

@ -1,6 +1,6 @@
{% extends 'generic/_base.html' %} {% extends 'generic/_base.html' %}
{% load helpers %}
{% load form_helpers %} {% load form_helpers %}
{% load helpers %}
{% load i18n %} {% load i18n %}
{% comment %} {% comment %}
@ -47,10 +47,17 @@ Context:
<form action="" method="post" enctype="multipart/form-data" class="form"> <form action="" method="post" enctype="multipart/form-data" class="form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="import_method" value="direct" /> <input type="hidden" name="import_method" value="direct" />
{# Form fields #}
{% render_field form.data %} {% render_field form.data %}
{% render_field form.format %} {% render_field form.format %}
{% render_field form.csv_delimiter %} {% render_field form.csv_delimiter %}
{# Meta fields #}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
{% render_field form.background_job %} {% render_field form.background_job %}
</div>
<div class="form-group"> <div class="form-group">
<div class="col col-md-12 text-end"> <div class="col col-md-12 text-end">
{% if return_url %} {% if return_url %}
@ -70,9 +77,12 @@ Context:
<form action="" method="post" enctype="multipart/form-data" class="form"> <form action="" method="post" enctype="multipart/form-data" class="form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="import_method" value="upload" /> <input type="hidden" name="import_method" value="upload" />
{# Form fields #}
{% render_field form.upload_file %} {% render_field form.upload_file %}
{% render_field form.format %} {% render_field form.format %}
{% render_field form.csv_delimiter %} {% render_field form.csv_delimiter %}
<div class="form-group"> <div class="form-group">
<div class="col col-md-12 text-end"> <div class="col col-md-12 text-end">
{% if return_url %} {% if return_url %}
@ -91,11 +101,18 @@ Context:
<form action="" method="post" enctype="multipart/form-data" class="form"> <form action="" method="post" enctype="multipart/form-data" class="form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="import_method" value="datafile" /> <input type="hidden" name="import_method" value="datafile" />
{# Form fields #}
{% render_field form.data_source %} {% render_field form.data_source %}
{% render_field form.data_file %} {% render_field form.data_file %}
{% render_field form.format %} {% render_field form.format %}
{% render_field form.csv_delimiter %} {% render_field form.csv_delimiter %}
{# Meta fields #}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
{% render_field form.background_job %} {% render_field form.background_job %}
</div>
<div class="form-group"> <div class="form-group">
<div class="col col-md-12 text-end"> <div class="col col-md-12 text-end">
{% if return_url %} {% if return_url %}

View File

@ -17,7 +17,7 @@ __all__ = (
) )
class UserBulkEditForm(forms.Form): class UserBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=User.objects.all(), queryset=User.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -55,7 +55,7 @@ class UserBulkEditForm(forms.Form):
nullable_fields = ('first_name', 'last_name') nullable_fields = ('first_name', 'last_name')
class GroupBulkEditForm(forms.Form): class GroupBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Group.objects.all(), queryset=Group.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -73,7 +73,7 @@ class GroupBulkEditForm(forms.Form):
nullable_fields = ('description',) nullable_fields = ('description',)
class ObjectPermissionBulkEditForm(forms.Form): class ObjectPermissionBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=ObjectPermission.objects.all(), queryset=ObjectPermission.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput

View File

@ -9,10 +9,11 @@ from django.utils.translation import gettext as _
from core.forms.mixins import SyncedDataMixin from core.forms.mixins import SyncedDataMixin
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
from utilities.constants import CSV_DELIMITERS from utilities.constants import CSV_DELIMITERS
from utilities.forms.mixins import BackgroundJobMixin
from utilities.forms.utils import parse_csv from utilities.forms.utils import parse_csv
class BulkImportForm(SyncedDataMixin, forms.Form): class BulkImportForm(BackgroundJobMixin, SyncedDataMixin, forms.Form):
import_method = forms.ChoiceField( import_method = forms.ChoiceField(
choices=ImportMethodChoices, choices=ImportMethodChoices,
required=False required=False
@ -37,11 +38,6 @@ class BulkImportForm(SyncedDataMixin, forms.Form):
help_text=_("The character which delimits CSV fields. Applies only to CSV format."), help_text=_("The character which delimits CSV fields. Applies only to CSV format."),
required=False required=False
) )
background_job = forms.BooleanField(
label=_('Background job'),
help_text=_("Enqueue a background job to complete the bulk import/update."),
required=False,
)
data_field = 'data' data_field = 'data'

View File

@ -3,6 +3,8 @@ import re
from django import forms from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from utilities.forms.mixins import BackgroundJobMixin
__all__ = ( __all__ = (
'BulkEditForm', 'BulkEditForm',
'BulkRenameForm', 'BulkRenameForm',
@ -28,9 +30,12 @@ class ConfirmationForm(forms.Form):
) )
class BulkEditForm(forms.Form): class BulkEditForm(BackgroundJobMixin, forms.Form):
""" """
Provides bulk edit support for objects. Provides bulk edit support for objects.
Attributes:
nullable_fields: A list of field names indicating which fields support being set to null/empty
""" """
nullable_fields = () nullable_fields = ()

View File

@ -6,11 +6,20 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
__all__ = ( __all__ = (
'BackgroundJobMixin',
'CheckLastUpdatedMixin', 'CheckLastUpdatedMixin',
'DistanceValidationMixin', 'DistanceValidationMixin',
) )
class BackgroundJobMixin(forms.Form):
background_job = forms.BooleanField(
label=_('Background job'),
help_text=_("Execute this task via a background job"),
required=False,
)
class CheckLastUpdatedMixin(forms.Form): class CheckLastUpdatedMixin(forms.Form):
""" """
Checks whether the object being saved has been updated since the form was initialized. If so, validation fails. Checks whether the object being saved has been updated since the form was initialized. If so, validation fails.

View File

@ -1,6 +1,10 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import List from typing import List
from django.contrib import messages
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from netbox.jobs import AsyncViewJob from netbox.jobs import AsyncViewJob
from utilities.request import copy_safe_request from utilities.request import copy_safe_request
@ -38,9 +42,19 @@ def process_request_as_job(view, request, name=None):
request_copy._background = True request_copy._background = True
# Enqueue a job to perform the work in the background # Enqueue a job to perform the work in the background
return AsyncViewJob.enqueue( job = AsyncViewJob.enqueue(
name=name, name=name,
user=request.user, user=request.user,
view_cls=view, view_cls=view,
request=request_copy, request=request_copy,
) )
# Record a message on the original request indicating deferral to a background job
msg = _('Created background job {id}: <a href="{url}">{name}</a>').format(
id=job.pk,
url=job.get_absolute_url(),
name=job.name
)
messages.info(request, mark_safe(msg))
return job