From ecdd4bf076a65e891ea5a5228f9b5a67dce51438 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 16 Jul 2025 13:35:55 -0400 Subject: [PATCH 1/7] Add background_job toggle to BulkEditForm --- netbox/netbox/forms/base.py | 7 +++---- netbox/users/forms/bulk_edit.py | 6 +++--- netbox/utilities/forms/forms.py | 9 +++++++++ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 632d5ecb107..57cfd180100 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -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 @@ -100,7 +100,7 @@ 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. @@ -108,9 +108,8 @@ class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form): 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 diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py index 52a022de33c..7c50f1ed1d8 100644 --- a/netbox/users/forms/bulk_edit.py +++ b/netbox/users/forms/bulk_edit.py @@ -17,7 +17,7 @@ ) -class UserBulkEditForm(forms.Form): +class UserBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=User.objects.all(), widget=forms.MultipleHiddenInput @@ -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 @@ -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 diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index d9bacbe8b40..63ac1cdb14c 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -31,9 +31,18 @@ class ConfirmationForm(forms.Form): class BulkEditForm(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 = () + background_job = forms.BooleanField( + label=_('Background job'), + help_text=_("Process as a job to edit objects in the background"), + required=False, + ) + class BulkRenameForm(forms.Form): """ From 27a7263f7ca89ca3787244400e8c7cb14769af3f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 16 Jul 2025 13:37:35 -0400 Subject: [PATCH 2/7] Account for bug fix in v4.3.4 --- netbox/netbox/jobs.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/jobs.py b/netbox/netbox/jobs.py index fea5e920040..72743eaf43f 100644 --- a/netbox/netbox/jobs.py +++ b/netbox/netbox/jobs.py @@ -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() From 76e0ee837b5992ac638f9987a5450685ef26bfc5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 16 Jul 2025 13:38:49 -0400 Subject: [PATCH 3/7] Enable background jobs for bulk edit & bulk delete --- netbox/netbox/views/generic/bulk_views.py | 52 ++++++++++++++++++++--- netbox/templates/generic/bulk_delete.html | 2 + netbox/templates/generic/bulk_edit.html | 2 + netbox/utilities/jobs.py | 16 ++++++- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index c207c3d8d66..d9541169b72 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -2,6 +2,7 @@ import re from copy import deepcopy +from django import forms from django.contrib import messages from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel from django.contrib.contenttypes.models import ContentType @@ -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}: {job.name}').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: @@ -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) @@ -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) @@ -878,6 +894,11 @@ def get_form(self): """ class BulkDeleteForm(ConfirmationForm): pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) + background_job = forms.BooleanField( + label=_('Background job'), + help_text=_("Process as a job to edit objects in the background"), + required=False, + ) return BulkDeleteForm @@ -908,6 +929,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() @@ -929,6 +959,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 diff --git a/netbox/templates/generic/bulk_delete.html b/netbox/templates/generic/bulk_delete.html index 4e3eecd8eab..c14695995f9 100644 --- a/netbox/templates/generic/bulk_delete.html +++ b/netbox/templates/generic/bulk_delete.html @@ -1,4 +1,5 @@ {% extends 'generic/_base.html' %} +{% load form_helpers %} {% load helpers %} {% load render_table from django_tables2 %} {% load i18n %} @@ -61,6 +62,7 @@

{% trans "Confirm Bulk Deletion" %}

{% for field in form.hidden_fields %} {{ field }} {% endfor %} + {% render_field form.background_job %}
{% trans "Cancel" %} diff --git a/netbox/templates/generic/bulk_edit.html b/netbox/templates/generic/bulk_edit.html index 8c4d305ecaa..dfcd870771f 100644 --- a/netbox/templates/generic/bulk_edit.html +++ b/netbox/templates/generic/bulk_edit.html @@ -89,6 +89,8 @@

{% trans "Comments" %}

{% endif %} + {% render_field form.background_job %} + {% else %} {# Render all fields #} diff --git a/netbox/utilities/jobs.py b/netbox/utilities/jobs.py index 800d5494990..50b2dbc0cd6 100644 --- a/netbox/utilities/jobs.py +++ b/netbox/utilities/jobs.py @@ -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 @@ -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}: {name}').format( + id=job.pk, + url=job.get_absolute_url(), + name=job.name + ) + messages.info(request, mark_safe(msg)) + + return job From f0a3f6462f6bcd851e68a00869130a0134ba37db Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 16 Jul 2025 13:54:01 -0400 Subject: [PATCH 4/7] Move background_job field to a mixin --- netbox/netbox/views/generic/bulk_views.py | 9 ++------- netbox/utilities/forms/bulk_import.py | 8 ++------ netbox/utilities/forms/forms.py | 10 +++------- netbox/utilities/forms/mixins.py | 9 +++++++++ 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index d9541169b72..68632688158 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -2,7 +2,6 @@ import re from copy import deepcopy -from django import forms from django.contrib import messages from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel from django.contrib.contenttypes.models import ContentType @@ -28,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 @@ -892,13 +892,8 @@ 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) - background_job = forms.BooleanField( - label=_('Background job'), - help_text=_("Process as a job to edit objects in the background"), - required=False, - ) return BulkDeleteForm diff --git a/netbox/utilities/forms/bulk_import.py b/netbox/utilities/forms/bulk_import.py index 0fa41f5709b..b11f6688a50 100644 --- a/netbox/utilities/forms/bulk_import.py +++ b/netbox/utilities/forms/bulk_import.py @@ -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 @@ -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' diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 63ac1cdb14c..2192c5a9974 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -3,6 +3,8 @@ from django import forms from django.utils.translation import gettext as _ +from utilities.forms.mixins import BackgroundJobMixin + __all__ = ( 'BulkEditForm', 'BulkRenameForm', @@ -28,7 +30,7 @@ class ConfirmationForm(forms.Form): ) -class BulkEditForm(forms.Form): +class BulkEditForm(BackgroundJobMixin, forms.Form): """ Provides bulk edit support for objects. @@ -37,12 +39,6 @@ class BulkEditForm(forms.Form): """ nullable_fields = () - background_job = forms.BooleanField( - label=_('Background job'), - help_text=_("Process as a job to edit objects in the background"), - required=False, - ) - class BulkRenameForm(forms.Form): """ diff --git a/netbox/utilities/forms/mixins.py b/netbox/utilities/forms/mixins.py index ca0f64e542e..e998b6adcc0 100644 --- a/netbox/utilities/forms/mixins.py +++ b/netbox/utilities/forms/mixins.py @@ -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. From 058c0674bef56c64b055c3b408cebc0cf40b8aea Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 16 Jul 2025 14:45:24 -0400 Subject: [PATCH 5/7] Cosmetic improvements --- netbox/templates/generic/bulk_delete.html | 11 ++++++++++- netbox/templates/generic/bulk_edit.html | 7 +++++-- netbox/templates/generic/bulk_import.html | 21 +++++++++++++++++++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/netbox/templates/generic/bulk_delete.html b/netbox/templates/generic/bulk_delete.html index c14695995f9..8590dd16512 100644 --- a/netbox/templates/generic/bulk_delete.html +++ b/netbox/templates/generic/bulk_delete.html @@ -59,14 +59,23 @@

{% trans "Confirm Bulk Deletion" %}

{% csrf_token %} + + {# Form fields #} {% for field in form.hidden_fields %} {{ field }} {% endfor %} - {% render_field form.background_job %} + + {# Meta fields #} +
+ {% render_field form.background_job %} +
+ + {# Form buttons #}
{% trans "Cancel" %}
+
diff --git a/netbox/templates/generic/bulk_edit.html b/netbox/templates/generic/bulk_edit.html index dfcd870771f..7af36eea09d 100644 --- a/netbox/templates/generic/bulk_edit.html +++ b/netbox/templates/generic/bulk_edit.html @@ -89,8 +89,6 @@

{% trans "Comments" %}

{% endif %} - {% render_field form.background_job %} - {% else %} {# Render all fields #} @@ -104,6 +102,11 @@

{% trans "Comments" %}

{% endif %} + {# Meta fields #} +
+ {% render_field form.background_job %} +
+
{% trans "Cancel" %} diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index b743f8b1587..ef2c104134f 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -47,10 +47,17 @@
{% csrf_token %} + + {# Form fields #} {% render_field form.data %} {% render_field form.format %} {% render_field form.csv_delimiter %} - {% render_field form.background_job %} + + {# Meta fields #} +
+ {% render_field form.background_job %} +
+
{% if return_url %} @@ -70,9 +77,12 @@ {% csrf_token %} + + {# Form fields #} {% render_field form.upload_file %} {% render_field form.format %} {% render_field form.csv_delimiter %} +
{% if return_url %} @@ -91,11 +101,18 @@ {% csrf_token %} + + {# 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 #} +
+ {% render_field form.background_job %} +
+
{% if return_url %} From 05472f326f01c4cef537feedb6bdd28f007456b2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 17 Jul 2025 09:06:11 -0400 Subject: [PATCH 6/7] Misc cleanup --- netbox/templates/generic/bulk_delete.html | 2 +- netbox/templates/generic/bulk_edit.html | 4 ++-- netbox/templates/generic/bulk_import.html | 2 +- netbox/utilities/forms/mixins.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox/templates/generic/bulk_delete.html b/netbox/templates/generic/bulk_delete.html index 8590dd16512..594efff63d7 100644 --- a/netbox/templates/generic/bulk_delete.html +++ b/netbox/templates/generic/bulk_delete.html @@ -1,8 +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: diff --git a/netbox/templates/generic/bulk_edit.html b/netbox/templates/generic/bulk_edit.html index 7af36eea09d..6aace8786fe 100644 --- a/netbox/templates/generic/bulk_edit.html +++ b/netbox/templates/generic/bulk_edit.html @@ -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: diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index ef2c104134f..f4a67cc1f7f 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -1,6 +1,6 @@ {% extends 'generic/_base.html' %} -{% load helpers %} {% load form_helpers %} +{% load helpers %} {% load i18n %} {% comment %} diff --git a/netbox/utilities/forms/mixins.py b/netbox/utilities/forms/mixins.py index e998b6adcc0..99e31649c74 100644 --- a/netbox/utilities/forms/mixins.py +++ b/netbox/utilities/forms/mixins.py @@ -12,7 +12,7 @@ ) -class BackgroundJobMixin(forms.Form): +class BackgroundJobMixin: background_job = forms.BooleanField( label=_('Background job'), help_text=_("Execute this task via a background job"), From c9688e7fca949da38928db1783f0939d3a298082 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 17 Jul 2025 09:33:46 -0400 Subject: [PATCH 7/7] Fix BackgroundJobMixin --- netbox/utilities/forms/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/forms/mixins.py b/netbox/utilities/forms/mixins.py index 99e31649c74..e998b6adcc0 100644 --- a/netbox/utilities/forms/mixins.py +++ b/netbox/utilities/forms/mixins.py @@ -12,7 +12,7 @@ ) -class BackgroundJobMixin: +class BackgroundJobMixin(forms.Form): background_job = forms.BooleanField( label=_('Background job'), help_text=_("Execute this task via a background job"),