diff --git a/netbox/ipam/forms/bulk_create.py b/netbox/ipam/forms/bulk_create.py index 856476786..763dbac9c 100644 --- a/netbox/ipam/forms/bulk_create.py +++ b/netbox/ipam/forms/bulk_create.py @@ -1,14 +1,17 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from utilities.forms.fields import ExpandableIPAddressField +from utilities.forms.fields import ExpandableIPNetworkField __all__ = ( - 'IPAddressBulkCreateForm', + 'IPNetworkBulkCreateForm', ) -class IPAddressBulkCreateForm(forms.Form): - pattern = ExpandableIPAddressField( - label=_('Address pattern') +class IPNetworkBulkCreateForm(forms.Form): + """ + Pattern form for bulk-creating IP-based objects (addresses, prefixes). + """ + pattern = ExpandableIPNetworkField( + label=_('Pattern') ) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 3748f2901..c8c488329 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -37,6 +37,7 @@ __all__ = ( 'IPAddressBulkAddForm', 'IPAddressForm', 'IPRangeForm', + 'PrefixBulkAddForm', 'PrefixForm', 'RIRForm', 'RoleForm', @@ -249,6 +250,23 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm): self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None) +class PrefixBulkAddForm(PrefixForm): + """ + Subclass of PrefixForm for bulk creation. The prefix field is inherited + but excluded from fieldsets — it is populated programmatically by BulkCreateView + from the expanded pattern. + """ + + fieldsets = ( + FieldSet( + 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix') + ), + FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('vlan', name=_('VLAN Assignment')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + ) + + class IPRangeForm(TenancyForm, PrimaryModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), @@ -472,6 +490,11 @@ class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm): label=_('VRF') ) + fieldsets = ( + FieldSet('status', 'role', 'vrf', 'dns_name', 'description', 'tags', name=_('IP Address')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + ) + class Meta: model = IPAddress fields = [ diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 5154397bb..04a88ace4 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -467,6 +467,74 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_bulk_add_ipv4_prefixes(self): + """Test bulk creating IPv4 prefixes using a pattern.""" + 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)) + + initial_count = Prefix.objects.count() + url = reverse('ipam:prefix_bulk_add') + data = { + 'pattern': '10.0.[0-2].0/24', + 'status': PrefixStatusChoices.STATUS_ACTIVE, + } + response = self.client.post(url, data) + self.assertHttpStatus(response, 302) + self.assertEqual(Prefix.objects.count(), initial_count + 3) + + for i in range(3): + self.assertTrue(Prefix.objects.filter(prefix=IPNetwork(f'10.0.{i}.0/24')).exists()) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_bulk_add_ipv6_prefixes(self): + """Test bulk creating IPv6 prefixes using a pattern.""" + 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)) + + initial_count = Prefix.objects.count() + url = reverse('ipam:prefix_bulk_add') + data = { + 'pattern': 'fd00:db8:[0-3]::/48', + 'status': PrefixStatusChoices.STATUS_ACTIVE, + } + response = self.client.post(url, data) + self.assertHttpStatus(response, 302) + self.assertEqual(Prefix.objects.count(), initial_count + 4) + + for i in range(4): + self.assertTrue(Prefix.objects.filter(prefix=IPNetwork(f'fd00:db8:{i}::/48')).exists()) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_bulk_add_ipv6_prefixes_uppercase_hex(self): + """Test bulk creating IPv6 prefixes using uppercase hex in the pattern.""" + 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)) + + initial_count = Prefix.objects.count() + url = reverse('ipam:prefix_bulk_add') + data = { + 'pattern': 'fd00:0:0:[48-4F]00::/56', + 'status': PrefixStatusChoices.STATUS_ACTIVE, + } + response = self.client.post(url, data) + self.assertHttpStatus(response, 302) + self.assertEqual(Prefix.objects.count(), initial_count + 8) + + expected_hex = ['48', '49', '4a', '4b', '4c', '4d', '4e', '4f'] + for h in expected_hex: + prefix_str = f'fd00:0:0:{h}00::/56' + self.assertTrue( + Prefix.objects.filter(prefix=IPNetwork(prefix_str)).exists(), + f'Expected prefix {prefix_str} was not created' + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_prefix_prefixes(self): prefixes = ( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 6ceb0c53a..77e1daa8c 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -714,6 +714,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView): class PrefixEditView(generic.ObjectEditView): queryset = Prefix.objects.all() form = forms.PrefixForm + template_name = 'ipam/prefix_edit.html' @register_model_view(Prefix, 'delete') @@ -721,6 +722,15 @@ class PrefixDeleteView(generic.ObjectDeleteView): queryset = Prefix.objects.all() +@register_model_view(Prefix, 'bulk_add', path='bulk-add', detail=False) +class PrefixBulkCreateView(generic.BulkCreateView): + queryset = Prefix.objects.all() + form = forms.IPNetworkBulkCreateForm + model_form = forms.PrefixBulkAddForm + pattern_target = 'prefix' + template_name = 'ipam/prefix_bulk_add.html' + + @register_model_view(Prefix, 'bulk_import', path='import', detail=False) class PrefixBulkImportView(generic.BulkImportView): queryset = Prefix.objects.all() @@ -979,7 +989,7 @@ class IPAddressDeleteView(generic.ObjectDeleteView): @register_model_view(IPAddress, 'bulk_add', path='bulk-add', detail=False) class IPAddressBulkCreateView(generic.BulkCreateView): queryset = IPAddress.objects.all() - form = forms.IPAddressBulkCreateForm + form = forms.IPNetworkBulkCreateForm model_form = forms.IPAddressBulkAddForm pattern_target = 'address' template_name = 'ipam/ipaddress_bulk_add.html' diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 2522a079a..25756e212 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -225,6 +225,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): form = None model_form = None pattern_target = '' + htmx_template_name = 'htmx/bulk_add_form.html' def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') @@ -254,6 +255,19 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): return new_objects + def _get_context(self, request, form, model_form): + model = self.queryset.model + return { + 'object': None, + 'obj_type': model._meta.verbose_name, + 'obj_type_plural': model._meta.verbose_name_plural, + 'form': form, + 'model_form': model_form, + 'return_url': self.get_return_url(request), + 'add_url': get_action_url(model, 'add'), + **self.get_extra_context(request), + } + # # Request handlers # @@ -268,13 +282,13 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): form = self.form() model_form = self.model_form(initial=initial) - return render(request, self.template_name, { - 'obj_type': self.model_form._meta.model._meta.verbose_name, - 'form': form, - 'model_form': model_form, - 'return_url': self.get_return_url(request), - **self.get_extra_context(request), - }) + # HTMX partial: only re-render the model form fields + if htmx_partial(request): + return render(request, self.htmx_template_name, { + 'model_form': model_form, + }) + + return render(request, self.template_name, self._get_context(request, form, model_form)) def post(self, request): logger = logging.getLogger('netbox.views.BulkCreateView') @@ -282,6 +296,12 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): form = self.form(request.POST) model_form = self.model_form(request.POST) + # HTMX partial: only re-render the model form fields + if htmx_partial(request): + return render(request, self.htmx_template_name, { + 'model_form': model_form, + }) + if form.is_valid(): logger.debug("Form validation was successful") @@ -313,13 +333,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): else: logger.debug("Form validation failed") - return render(request, self.template_name, { - 'form': form, - 'model_form': model_form, - 'obj_type': model._meta.verbose_name, - 'return_url': self.get_return_url(request), - **self.get_extra_context(request), - }) + return render(request, self.template_name, self._get_context(request, form, model_form)) class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): diff --git a/netbox/templates/generic/bulk_add.html b/netbox/templates/generic/bulk_add.html new file mode 100644 index 000000000..b9c3999a1 --- /dev/null +++ b/netbox/templates/generic/bulk_add.html @@ -0,0 +1,51 @@ +{% extends 'generic/object_edit.html' %} +{% load helpers %} +{% load form_helpers %} +{% load i18n %} + +{% block title %}{% blocktrans trimmed with object_type_plural=obj_type_plural %}Bulk Add {{ object_type_plural }}{% endblocktrans %}{% endblock %} + +{% block tabs %} +
+{% endblock %} + +{% block pre_form_fields %} +