Enable background jobs for bulk edit & bulk delete

This commit is contained in:
Jeremy Stretch 2025-07-16 13:38:49 -04:00
parent 27a7263f7c
commit 76e0ee837b
4 changed files with 65 additions and 7 deletions

View File

@ -2,6 +2,7 @@ import logging
import re import re
from copy import deepcopy from copy import deepcopy
from django import forms
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -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)
@ -878,6 +894,11 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
""" """
class BulkDeleteForm(ConfirmationForm): class BulkDeleteForm(ConfirmationForm):
pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) 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 return BulkDeleteForm
@ -908,6 +929,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 +959,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,4 +1,5 @@
{% extends 'generic/_base.html' %} {% extends 'generic/_base.html' %}
{% load form_helpers %}
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% load i18n %} {% load i18n %}
@ -61,6 +62,7 @@ Context:
{% for field in form.hidden_fields %} {% for field in form.hidden_fields %}
{{ field }} {{ field }}
{% endfor %} {% endfor %}
{% render_field form.background_job %}
<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>

View File

@ -89,6 +89,8 @@ Context:
</div> </div>
{% endif %} {% endif %}
{% render_field form.background_job %}
{% else %} {% else %}
{# Render all fields #} {# Render all fields #}

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