From 775d6aa9362d2bc310a39f967b89699e68696222 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Fri, 13 Mar 2026 15:10:46 +0100 Subject: [PATCH] feat(ipam): Add HTMX support to prefix bulk add form Enable dynamic form updates in the prefix bulk add view by introducing HTMX partial rendering. Inherit from PrefixForm to support scope and VLAN fields, and add htmx_template_name for efficient field updates. --- netbox/ipam/forms/model_forms.py | 31 +++++++------------ netbox/netbox/views/generic/bulk_views.py | 13 ++++++++ netbox/templates/generic/bulk_add.html | 6 ++-- netbox/templates/generic/object_edit.html | 1 + netbox/templates/htmx/bulk_add_form.html | 22 +++++++++++++ .../ipam/inc/prefix_edit_header.html | 4 +-- 6 files changed, 53 insertions(+), 24 deletions(-) create mode 100644 netbox/templates/htmx/bulk_add_form.html diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index d3159320d..c8c488329 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -250,31 +250,22 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm): self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None) -class PrefixBulkAddForm(TenancyForm, NetBoxModelForm): - vrf = DynamicModelChoiceField( - queryset=VRF.objects.all(), - required=False, - label=_('VRF') - ) - role = DynamicModelChoiceField( - label=_('Role'), - queryset=Role.objects.all(), - required=False, - quick_add=True - ) +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', 'role', 'vrf', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix')), + 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 Meta: - model = Prefix - fields = [ - 'prefix', 'vrf', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'tenant_group', 'tenant', - 'tags', - ] - class IPRangeForm(TenancyForm, PrimaryModelForm): vrf = DynamicModelChoiceField( diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index ec18412a4..9db40a0b0 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') @@ -280,6 +281,12 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): form = self.form() model_form = self.model_form(initial=initial) + # 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): @@ -288,6 +295,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") diff --git a/netbox/templates/generic/bulk_add.html b/netbox/templates/generic/bulk_add.html index ebc7bc0be..b9c3999a1 100644 --- a/netbox/templates/generic/bulk_add.html +++ b/netbox/templates/generic/bulk_add.html @@ -20,14 +20,16 @@ {% endblock %} -{% block form %} +{% block pre_form_fields %}

{% trans "Pattern" %}

{% render_field form.pattern %}
+{% endblock pre_form_fields %} +{% block form %} {% if model_form.fieldsets %} {% for fieldset in model_form.fieldsets %} {% render_fieldset model_form fieldset %} @@ -46,4 +48,4 @@ {% render_custom_fields model_form %} {% endif %} -{% endblock %} +{% endblock form %} diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 3e0a096fa..c24c27929 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -61,6 +61,7 @@ Context:
{% csrf_token %} + {% block pre_form_fields %}{% endblock pre_form_fields %}
{% block form %} {% include 'htmx/form.html' %} diff --git a/netbox/templates/htmx/bulk_add_form.html b/netbox/templates/htmx/bulk_add_form.html new file mode 100644 index 000000000..7ea742b30 --- /dev/null +++ b/netbox/templates/htmx/bulk_add_form.html @@ -0,0 +1,22 @@ +{% load helpers %} +{% load form_helpers %} +{% load i18n %} + +{% if model_form.fieldsets %} + {% for fieldset in model_form.fieldsets %} + {% render_fieldset model_form fieldset %} + {% endfor %} +{% else %} +
+ {% render_form model_form %} +
+{% endif %} + +{% if model_form.custom_fields %} +
+
+

{% trans "Custom Fields" %}

+
+ {% render_custom_fields model_form %} +
+{% endif %} diff --git a/netbox/templates/ipam/inc/prefix_edit_header.html b/netbox/templates/ipam/inc/prefix_edit_header.html index db8c12d46..bc63defe9 100644 --- a/netbox/templates/ipam/inc/prefix_edit_header.html +++ b/netbox/templates/ipam/inc/prefix_edit_header.html @@ -3,8 +3,8 @@