feat(ipam): Add changelog message support to bulk Prefix/IP creation

Extend bulk add forms for Prefix and IPAddress to support changelog
messages. Switch IPAddressBulkAddForm to PrimaryModelForm base, update
field ordering, consolidate template rendering, and add test coverage.

Fixes #21780
This commit is contained in:
Martin Hauser
2026-04-06 20:15:02 +02:00
parent 02f9ca8f01
commit d630afaf14
5 changed files with 77 additions and 48 deletions

View File

@@ -483,7 +483,7 @@ class IPAddressForm(TenancyForm, PrimaryModelForm):
return ipaddress return ipaddress
class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm): class IPAddressBulkAddForm(TenancyForm, PrimaryModelForm):
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
@@ -498,7 +498,8 @@ class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags', 'address', 'vrf', 'status', 'role', 'dns_name', 'tenant_group', 'tenant', 'description', 'owner',
'comments', 'tags',
] ]

View File

@@ -5,7 +5,8 @@ from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from netaddr import IPNetwork from netaddr import IPNetwork
from core.models import ObjectType from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange, ObjectType
from dcim.constants import InterfaceTypeChoices from dcim.constants import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
from ipam.choices import * from ipam.choices import *
@@ -543,6 +544,37 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
f'Expected prefix {prefix_str} was not created' f'Expected prefix {prefix_str} was not created'
) )
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_bulk_add_prefixes_with_changelog_message(self):
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(Prefix))
changelog_message = 'Bulk-created prefixes'
prefixes = [IPNetwork(f'198.18.{i}.0/24') for i in range(3)]
url = reverse('ipam:prefix_bulk_add')
data = {
'pattern': '198.18.[0-2].0/24',
'status': PrefixStatusChoices.STATUS_ACTIVE,
'changelog_message': changelog_message,
}
response = self.client.post(url, data)
self.assertHttpStatus(response, 302)
created_prefixes = list(Prefix.objects.filter(prefix__in=prefixes))
self.assertEqual(len(created_prefixes), len(prefixes))
objectchanges = ObjectChange.objects.filter(
action=ObjectChangeActionChoices.ACTION_CREATE,
changed_object_type=ContentType.objects.get_for_model(Prefix),
changed_object_id__in=[obj.pk for obj in created_prefixes],
)
self.assertEqual(objectchanges.count(), len(prefixes))
for objectchange in objectchanges:
self.assertEqual(objectchange.message, changelog_message)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_prefixes(self): def test_prefix_prefixes(self):
prefixes = ( prefixes = (
@@ -908,6 +940,39 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description', 'description': 'New description',
} }
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_bulk_add_ipaddresses_with_changelog_message(self):
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(IPAddress))
vrf = VRF.objects.get(name='VRF 1')
changelog_message = 'Bulk-created IP addresses'
addresses = [IPNetwork(f'198.51.100.{i}/24') for i in range(10, 13)]
url = reverse('ipam:ipaddress_bulk_add')
data = {
'pattern': '198.51.100.[10-12]/24',
'vrf': vrf.pk,
'status': IPAddressStatusChoices.STATUS_ACTIVE,
'changelog_message': changelog_message,
}
response = self.client.post(url, data)
self.assertHttpStatus(response, 302)
created_addresses = list(IPAddress.objects.filter(address__in=addresses, vrf=vrf))
self.assertEqual(len(created_addresses), len(addresses))
objectchanges = ObjectChange.objects.filter(
action=ObjectChangeActionChoices.ACTION_CREATE,
changed_object_type=ContentType.objects.get_for_model(IPAddress),
changed_object_id__in=[obj.pk for obj in created_addresses],
)
self.assertEqual(objectchanges.count(), len(addresses))
for objectchange in objectchanges:
self.assertEqual(objectchange.message, changelog_message)
class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase): class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = FHRPGroup model = FHRPGroup

View File

@@ -243,6 +243,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
# Validate each new object independently. # Validate each new object independently.
if model_form.is_valid(): if model_form.is_valid():
model_form.instance._changelog_message = model_form.cleaned_data.get('changelog_message', '')
obj = model_form.save() obj = model_form.save()
new_objects.append(obj) new_objects.append(obj)
else: else:

View File

@@ -21,31 +21,14 @@
{% endblock %} {% endblock %}
{% block pre_form_fields %} {% block pre_form_fields %}
<div class="field-group my-5"> <div class="field-group my-5">
<div class="row"> <div class="row">
<h2 class="col-9 offset-3">{% trans "Pattern" %}</h2> <h2 class="col-9 offset-3">{% trans "Pattern" %}</h2>
</div>
{% render_field form.pattern %}
</div> </div>
{% render_field form.pattern %}
</div>
{% endblock pre_form_fields %} {% endblock pre_form_fields %}
{% block form %} {% block form %}
{% if model_form.fieldsets %} {% include 'htmx/form.html' with form=model_form %}
{% for fieldset in model_form.fieldsets %}
{% render_fieldset model_form fieldset %}
{% endfor %}
{% else %}
<div class="field-group my-5">
{% render_form model_form %}
</div>
{% endif %}
{% if model_form.custom_fields %}
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Custom Fields" %}</h2>
</div>
{% render_custom_fields model_form %}
</div>
{% endif %}
{% endblock form %} {% endblock form %}

View File

@@ -1,22 +1 @@
{% load helpers %} {% include 'htmx/form.html' with form=model_form %}
{% load form_helpers %}
{% load i18n %}
{% if model_form.fieldsets %}
{% for fieldset in model_form.fieldsets %}
{% render_fieldset model_form fieldset %}
{% endfor %}
{% else %}
<div class="field-group my-5">
{% render_form model_form %}
</div>
{% endif %}
{% if model_form.custom_fields %}
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Custom Fields" %}</h2>
</div>
{% render_custom_fields model_form %}
</div>
{% endif %}