Compare commits

..

5 Commits

Author SHA1 Message Date
bctiemann
74aa822b27 Merge pull request #21762 from netbox-community/20162-background
#20162 allow background job when adding components to devices in bulk
2026-03-27 13:02:40 -04:00
Arthur
9bc66ee0bf cleanup 2026-03-26 15:00:52 -07:00
Arthur
3ec0551680 cleanup 2026-03-26 13:37:40 -07:00
Arthur
8a58d760fa cleanup 2026-03-26 13:25:49 -07:00
Arthur
84670af18b #20162 allow background job when adding components to devices in bulk 2026-03-26 09:56:21 -07:00
5 changed files with 46 additions and 61 deletions

View File

@@ -3,9 +3,10 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import *
from extras.models import Tag
from netbox.forms.mixins import ChangelogMessageMixin, CustomFieldsMixin
from netbox.forms.mixins import CustomFieldsMixin
from utilities.forms import form_from_model
from utilities.forms.fields import DynamicModelMultipleChoiceField, ExpandableNameField
from utilities.forms.mixins import BackgroundJobMixin
from .object_create import ComponentCreateForm
@@ -27,8 +28,7 @@ __all__ = (
# Device components
#
class DeviceBulkAddComponentForm(ChangelogMessageMixin, CustomFieldsMixin, ComponentCreateForm):
class DeviceBulkAddComponentForm(BackgroundJobMixin, CustomFieldsMixin, ComponentCreateForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()

View File

@@ -3,13 +3,11 @@ from decimal import Decimal
from zoneinfo import ZoneInfo
import yaml
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings, tag
from django.urls import reverse
from netaddr import EUI
from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange, ObjectType
from core.models import ObjectType
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
@@ -2743,50 +2741,6 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
f"{console_ports[2].pk},Console Port 9,New description9",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_add_components_with_changelog_message(self):
device1 = Device.objects.get(name='Device 1')
device2 = create_test_device('Device 2')
changelog_message = 'Bulk-created console ports'
obj_perm = ObjectPermission(
name='Test permission',
actions=['add'],
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
request = {
'path': reverse('dcim:device_bulk_add_consoleport'),
'data': post_data({
'pk': [device1.pk, device2.pk],
'name': 'Console Port Bulk',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'Bulk-created console port',
'changelog_message': changelog_message,
'_create': True,
}),
}
initial_count = self._get_queryset().count()
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
self.assertEqual(initial_count + 2, self._get_queryset().count())
created_ports = list(ConsolePort.objects.filter(name='Console Port Bulk').order_by('device_id'))
self.assertEqual(len(created_ports), 2)
self.assertEqual([port.device_id for port in created_ports], [device1.pk, device2.pk])
objectchanges = ObjectChange.objects.filter(
action=ObjectChangeActionChoices.ACTION_CREATE,
changed_object_type=ContentType.objects.get_for_model(ConsolePort),
changed_object_id__in=[port.pk for port in created_ports],
)
self.assertEqual(objectchanges.count(), 2)
for objectchange in objectchanges:
self.assertEqual(objectchange.message, changelog_message)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
consoleport = ConsolePort.objects.first()

View File

@@ -1137,9 +1137,18 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
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 add {count} {object_type}').format(
count=len(form.cleaned_data['pk']),
object_type=self.queryset.model._meta.verbose_name_plural,
)
if process_request_as_job(self.__class__, request, name=job_name):
return redirect(self.get_return_url(request))
new_components = []
data = deepcopy(form.cleaned_data)
changelog_message = data.pop('changelog_message', '')
data.pop('background_job', None)
replication_data = {
field: data.pop(field) for field in form.replication_fields
}
@@ -1161,15 +1170,16 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
component_form = self.model_form(component_data)
if component_form.is_valid():
if changelog_message:
component_form.instance._changelog_message = changelog_message
instance = component_form.save()
logger.debug(f"Created {instance} on {instance.parent_object}")
new_components.append(instance)
else:
for field, errors in component_form.errors.as_data().items():
for e in errors:
form.add_error(field, '{}: {}'.format(obj, ', '.join(e)))
err_msg = '{}: {}'.format(obj, ', '.join(e))
form.add_error(field, err_msg)
if is_background_request(request):
request.job.logger.error(err_msg)
# Enforce object-level permissions
component_ids = [obj.pk for obj in new_components]
@@ -1178,20 +1188,32 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
except IntegrityError:
clear_events.send(sender=self)
if is_background_request(request):
request.job.logger.error(_("An integrity error occurred while creating components"))
raise JobFailed
except (AbortRequest, PermissionsViolation) as e:
logger.debug(e.message)
form.add_error(None, e.message)
clear_events.send(sender=self)
if is_background_request(request):
request.job.logger.error(e.message)
raise JobFailed
if not form.errors:
msg = "Added {} {} to {} {}.".format(
len(new_components),
model_name,
len(form.cleaned_data['pk']),
parent_model_name
msg = _("Added {count} {component} to {parent_count} {parent}.").format(
count=len(new_components),
component=model_name,
parent_count=len(form.cleaned_data['pk']),
parent=parent_model_name,
)
logger.info(msg)
# Handle background job
if is_background_request(request):
request.job.logger.info(msg)
return None
messages.success(request, msg)
return redirect(self.get_return_url(request))

View File

@@ -58,10 +58,18 @@ Context:
<h2 class="card-header">{{ model_name|title }} {% trans "to Add" %}</h2>
<div class="card-body">
{% for field in form.visible_fields %}
{% render_field field %}
{% if not form.meta_fields or field.name not in form.meta_fields %}
{% render_field field %}
{% endif %}
{% endfor %}
</div>
</div>
{# Meta fields #}
{% if form.background_job %}
<div class="bg-primary-subtle border border-primary rounded-1 pt-3 px-3 mb-3">
{% render_field form.background_job %}
</div>
{% endif %}
<div class="form-group text-end">
<div class="col col-md-12">
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>

View File

@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from utilities.forms import form_from_model
from utilities.forms.fields import ExpandableNameField
from utilities.forms.mixins import BackgroundJobMixin
from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
__all__ = (
@@ -11,7 +12,7 @@ __all__ = (
)
class VirtualMachineBulkAddComponentForm(forms.Form):
class VirtualMachineBulkAddComponentForm(BackgroundJobMixin, forms.Form):
pk = forms.ModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(),
widget=forms.MultipleHiddenInput()