Skip to content

Closes #19891: Bulk operation jobs #19897

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions netbox/netbox/forms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from core.models import ObjectType
from extras.choices import *
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.mixins import CheckLastUpdatedMixin
from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
Expand Down Expand Up @@ -100,17 +100,16 @@ def _get_form_field(self, customfield):
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
fields and adding/removing tags.

Attributes:
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.
nullable_fields: A list of field names indicating which fields support being set to null/empty
"""
nullable_fields = ()
fieldsets = None

pk = forms.ModelMultipleChoiceField(
queryset=None, # Set from self.model on init
Expand Down
5 changes: 2 additions & 3 deletions netbox/netbox/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,5 @@ def run(self, view_cls, request, **kwargs):
)
notification.save()

# TODO: Waiting on fix for bug #19806
# if errors:
# raise JobFailed()
if data.errors:
raise JobFailed()
49 changes: 42 additions & 7 deletions netbox/netbox/views/generic/bulk_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from utilities.export import TableExport
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
from utilities.forms.bulk_import import BulkImportForm
from utilities.forms.mixins import BackgroundJobMixin
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
Expand Down Expand Up @@ -513,12 +514,7 @@ def post(self, request):
count=len(form.cleaned_data['data']),
object_type=model._meta.verbose_name_plural,
)
if job := 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))
if process_request_as_job(self.__class__, request, name=job_name):
return redirect(redirect_url)

try:
Expand Down Expand Up @@ -712,6 +708,16 @@ def post(self, request, **kwargs):
if '_apply' in request.POST:
if form.is_valid():
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:
with transaction.atomic(using=router.db_for_write(model)):
updated_objects = self._update_objects(form, request)
Expand All @@ -721,6 +727,16 @@ def post(self, request, **kwargs):
if object_count != len(updated_objects):
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:
msg = f'Updated {len(updated_objects)} {model._meta.verbose_name_plural}'
logger.info(msg)
Expand Down Expand Up @@ -876,7 +892,7 @@ def get_form(self):
"""
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)

return BulkDeleteForm
Expand Down Expand Up @@ -908,6 +924,15 @@ def post(self, request, **kwargs):
if form.is_valid():
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
queryset = self.queryset.filter(pk__in=pk_list)
deleted_count = queryset.count()
Expand All @@ -929,6 +954,16 @@ def post(self, request, **kwargs):
messages.error(request, mark_safe(e.message))
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(
count=deleted_count,
object_type=model._meta.verbose_name_plural
Expand Down
13 changes: 12 additions & 1 deletion netbox/templates/generic/bulk_delete.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{% extends 'generic/_base.html' %}
{% load form_helpers %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% load render_table from django_tables2 %}

{% comment %}
Blocks:
Expand Down Expand Up @@ -58,13 +59,23 @@ <h4 class="alert-title">{% trans "Confirm Bulk Deletion" %}</h4>
<div class="row mt-3">
<form action="" method="post">
{% csrf_token %}

{# Form fields #}
{% for field in form.hidden_fields %}
{{ field }}
{% 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">
<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>
</div>

</form>
</div>
</div>
Expand Down
9 changes: 7 additions & 2 deletions netbox/templates/generic/bulk_edit.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{% extends 'generic/_base.html' %}
{% load helpers %}
{% load form_helpers %}
{% load render_table from django_tables2 %}
{% load helpers %}
{% load i18n %}
{% load render_table from django_tables2 %}

{% comment %}
Blocks:
Expand Down Expand Up @@ -102,6 +102,11 @@ <h2 class="col-9 offset-3">{% trans "Comments" %}</h2>

{% 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">
<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>
Expand Down
23 changes: 20 additions & 3 deletions netbox/templates/generic/bulk_import.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{% extends 'generic/_base.html' %}
{% load helpers %}
{% load form_helpers %}
{% load helpers %}
{% load i18n %}

{% comment %}
Expand Down Expand Up @@ -47,10 +47,17 @@
<form action="" method="post" enctype="multipart/form-data" class="form">
{% csrf_token %}
<input type="hidden" name="import_method" value="direct" />

{# Form fields #}
{% render_field form.data %}
{% render_field form.format %}
{% render_field form.csv_delimiter %}
{% render_field form.background_job %}

{# Meta fields #}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
{% render_field form.background_job %}
</div>

<div class="form-group">
<div class="col col-md-12 text-end">
{% if return_url %}
Expand All @@ -70,9 +77,12 @@
<form action="" method="post" enctype="multipart/form-data" class="form">
{% csrf_token %}
<input type="hidden" name="import_method" value="upload" />

{# Form fields #}
{% render_field form.upload_file %}
{% render_field form.format %}
{% render_field form.csv_delimiter %}

<div class="form-group">
<div class="col col-md-12 text-end">
{% if return_url %}
Expand All @@ -91,11 +101,18 @@
<form action="" method="post" enctype="multipart/form-data" class="form">
{% csrf_token %}
<input type="hidden" name="import_method" value="datafile" />

{# Form fields #}
{% render_field form.data_source %}
{% render_field form.data_file %}
{% render_field form.format %}
{% render_field form.csv_delimiter %}
{% render_field form.background_job %}

{# Meta fields #}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 mb-3">
{% render_field form.background_job %}
</div>

<div class="form-group">
<div class="col col-md-12 text-end">
{% if return_url %}
Expand Down
6 changes: 3 additions & 3 deletions netbox/users/forms/bulk_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
)


class UserBulkEditForm(forms.Form):
class UserBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=User.objects.all(),
widget=forms.MultipleHiddenInput
Expand Down Expand Up @@ -55,7 +55,7 @@ class UserBulkEditForm(forms.Form):
nullable_fields = ('first_name', 'last_name')


class GroupBulkEditForm(forms.Form):
class GroupBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Group.objects.all(),
widget=forms.MultipleHiddenInput
Expand All @@ -73,7 +73,7 @@ class GroupBulkEditForm(forms.Form):
nullable_fields = ('description',)


class ObjectPermissionBulkEditForm(forms.Form):
class ObjectPermissionBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ObjectPermission.objects.all(),
widget=forms.MultipleHiddenInput
Expand Down
8 changes: 2 additions & 6 deletions netbox/utilities/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
from core.forms.mixins import SyncedDataMixin
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
from utilities.constants import CSV_DELIMITERS
from utilities.forms.mixins import BackgroundJobMixin
from utilities.forms.utils import parse_csv


class BulkImportForm(SyncedDataMixin, forms.Form):
class BulkImportForm(BackgroundJobMixin, SyncedDataMixin, forms.Form):
import_method = forms.ChoiceField(
choices=ImportMethodChoices,
required=False
Expand All @@ -37,11 +38,6 @@ class BulkImportForm(SyncedDataMixin, forms.Form):
help_text=_("The character which delimits CSV fields. Applies only to CSV format."),
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'

Expand Down
7 changes: 6 additions & 1 deletion netbox/utilities/forms/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from django import forms
from django.utils.translation import gettext as _

from utilities.forms.mixins import BackgroundJobMixin

__all__ = (
'BulkEditForm',
'BulkRenameForm',
Expand All @@ -28,9 +30,12 @@ class ConfirmationForm(forms.Form):
)


class BulkEditForm(forms.Form):
class BulkEditForm(BackgroundJobMixin, forms.Form):
"""
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 = ()

Expand Down
9 changes: 9 additions & 0 deletions netbox/utilities/forms/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,20 @@
from django.utils.translation import gettext_lazy as _

__all__ = (
'BackgroundJobMixin',
'CheckLastUpdatedMixin',
'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):
"""
Checks whether the object being saved has been updated since the form was initialized. If so, validation fails.
Expand Down
16 changes: 15 additions & 1 deletion netbox/utilities/jobs.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from dataclasses import dataclass
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 utilities.request import copy_safe_request

Expand Down Expand Up @@ -38,9 +42,19 @@ def process_request_as_job(view, request, name=None):
request_copy._background = True

# Enqueue a job to perform the work in the background
return AsyncViewJob.enqueue(
job = AsyncViewJob.enqueue(
name=name,
user=request.user,
view_cls=view,
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