Forms#
Forms live under netbox_aci_plugin/forms/<domain>/<model>.py. The
plugin ships four forms per primary model, all in the same file
per model. Every form uses utilities.forms.rendering.FieldSet to
organize fields into named groups.
Four-form suite per primary model#
<Model>EditFormextendsNetBoxModelFormand handles create/edit behavior. Most form logic lives here.<Model>BulkEditFormextendsNetBoxModelBulkEditFormand declares the fields that make sense in bulk, plusnullable_fields.<Model>FilterFormextendsNetBoxModelFilterSetFormand powers the list-view filter sidebar. It mirrors the FilterSet's fields.<Model>ImportFormextendsNetBoxModelImportFormand handles CSV import. It usesCSVChoiceField/CSVModelChoiceFieldfor foreign keys.
Even when a form has no domain-specific fields beyond the inherited ones (e.g. a relation/binding's BulkEditForm), declare the class anyway so the four-form suite stays uniform across models.
Auto-rendered sections (Ownership, Tags, Comments)#
NetBox's form templates auto-render certain sections. Declaring them
in fieldsets causes them to render twice in the browser.
The auto-rendered sections are:
NetBoxModelForm/htmx/form.html: Ownership and Comments.NetBoxModelBulkEditForm/generic/bulk_edit.html: Ownership, Tags, and Comments.NetBoxModelFilterSetForm: no auto-rendered sections.
Rules:
- EditForm: declare domain-specific FieldSets plus
TagsandNetBox Tenancy. Do not add an Ownership or Comments FieldSet. - BulkEditForm: declare domain-specific FieldSets plus
NetBox Tenancy. Do not add Ownership, Tags, or Comments FieldSets. Still listcommentsandnb_tenantinnullable_fieldsso the bulk-nullable checkboxes render. - FilterForm: an explicit
OwnershipFieldSet is required (the filter template has no auto-rendered section). Useowner_group_id/owner_idfield names.
Reference: NetBox core's dcim/forms/model_forms.py (SiteForm,
RackForm, etc.) follows the same pattern, with no explicit Ownership or
Comments FieldSets on EditForms.
Field declaration order#
EditForm#
Declare fields in this order (skip any not applicable):
- Parent FK cascade (
aci_fabric,aci_tenant,aci_vrf, ...) - Domain-specific / feature fields (
security_domains,target_dscp, ...) nb_tenant_group,nb_tenant(NetBox tenancy)owner_group,owner(ownership)comments(always last)
BulkEditForm#
- Identity fields (
name_alias,description) - Parent FK reparenting fields (
aci_fabric,aci_bridge_domain, ...) - Domain-specific / feature fields
nb_tenant(NetBox tenancy)owner(ownership)comments(always last)
FilterForm#
Body structure (order of class-level attributes):
model = <Model>fieldsets: tuple = (...)- Field declarations (
aci_fabric_id,name, ...) tag = TagFilterField(model)(always last field)
FilterForm fieldsets AND field declarations must use identity-first
order: name, name_alias, description first, then FK/scope
fields (aci_fabric_id, aci_tenant_id, etc.), then domain-specific
fields. Past that identity-first prefix, the class body follows the
model's own field order, while fieldsets are free to regroup the same
fields into function-based sections (e.g. "Policy Control Settings",
"Multicast Settings"). Body order and fieldset order are allowed to
diverge beyond the identity-first fields - don't reorder the body to
mirror the fieldsets.
Choice fields: blank values belong to bulk edit and filtering#
A ChoiceField on an EditForm that maps to a model field carrying a
default and blank=False stays required, which is the Django default,
so simply omit required=False. Marking it optional looks harmless but
lets an empty submitted value through: Django treats a present-but-empty
value as a real value rather than an omission, assigns "" to the
instance, and then skips the field during model validation precisely
because the model forbids blanks while the form does not require one.
The result is a stored empty string that matches no choice, so detail
views and colour helpers render nothing. Omitting the field entirely is
still safe - the model default applies - which is why the ImportForm
keeps required=False and falls back through its
_clean_field_default_* helpers.
For the same reason an EditForm never wraps its choices in
add_blank_choice(). Where the ACI object model needs a neutral value,
the ChoiceSet already carries an explicit member for it (for example
unspecified on the QoS and DSCP sets); pair that member with
initial= instead of offering a blank entry that validation rejects.
The blank entry does belong on a BulkEditForm, where it means "leave
this field unchanged", and on a FilterForm, where it means "do not
filter on this field".
The one edit-form exception is a choice field paired with a
<field>_custom input, built with add_custom_choice() from
choices.py. There the trailing None entry is a working "custom"
sentinel rather than a placeholder: the user picks it, types a numeric
value into the paired field, and the form's clean() substitutes that
value. Those fields keep required=False because clean() relies on
the choice arriving empty. ACIContractFilterEntryEditForm is the only
current example.
tests/forms/test_choice_field_conventions.py enforces all of the
above by walking every edit form at runtime, so a new form cannot
reintroduce either mistake.
# EditForm - required, no blank entry
target_dscp = forms.ChoiceField(
choices=QualityOfServiceDSCPChoices,
initial=QualityOfServiceDSCPChoices.DSCP_UNSPECIFIED,
label=_("Target DSCP"),
)
# BulkEditForm - optional, blank means "leave unchanged"
target_dscp = forms.ChoiceField(
choices=add_blank_choice(QualityOfServiceDSCPChoices),
required=False,
label=_("Target DSCP"),
)
Meta.fields ordering (EditForm and ImportForm)#
class Meta:
model = <Model>
fields: tuple = (
"name",
"name_alias",
"description",
# parent FKs (aci_fabric, aci_tenant, aci_vrf, ...)
# domain-specific / feature fields
"nb_tenant",
"owner",
"comments",
"tags",
)
Identity fields first, parent FKs next, then domain-specific,
then nb_tenant, owner, comments, tags last.
FieldSet fieldset organization#
Every Edit, BulkEdit, and Filter form declares a fieldsets tuple of
FieldSet(...) calls, with no raw fields list at the form level
(Meta still has fields; this is about presentational grouping).
ImportForm never declares fieldsets: CSV import renders as a flat
column-mapped table, not a sectioned web form. Each FieldSet takes
positional field names and a name= kwarg holding the section
heading:
from utilities.forms.rendering import FieldSet
class ACIBridgeDomainEditForm(NetBoxModelForm):
# ... field declarations ...
fieldsets: tuple = (
FieldSet(
"name",
"name_alias",
"aci_fabric",
"aci_tenant",
"aci_vrf",
"description",
"tags",
name=_("ACI Bridge Domain"),
),
FieldSet(
"unicast_routing_enabled",
"advertise_host_routes_enabled",
"ep_move_detection_enabled",
"mac_address",
"virtual_mac_address",
name=_("Routing Settings"),
),
# ...
FieldSet(
"nb_tenant_group",
"nb_tenant",
name=_("NetBox Tenancy"),
),
)
The fieldset names are user-facing; wrap them with _(). Order
fieldsets by logical importance (identity, behavior, scoping,
tags/comments). The last fieldset is usually NetBox Tenancy (or
Tags / Comments for narrow forms).
Cascading dropdowns#
Use DynamicModelChoiceField from utilities.forms.fields. The two
mechanisms:
query_params: server-side filter applied when the dropdown fetches options. References other form fields with$<fieldname>.initial_params: runs once on bind to look up an existing related-object value (for edit mode). Use a single key; multi-keyinitial_paramsbehaves as AND and breaks when one side lives incommon.
aci_fabric = DynamicModelChoiceField(
queryset=ACIFabric.objects.all(),
initial_params={"aci_tenants": "$aci_tenant"},
required=False,
label=_("ACI Fabric"),
)
aci_tenant = DynamicModelChoiceField(
queryset=ACITenant.objects.all(),
query_params={"aci_fabric_id": "$aci_fabric"},
label=_("ACI Tenant"),
)
aci_vrf = DynamicModelChoiceField(
queryset=ACIVRF.objects.all(),
query_params={
"aci_fabric_id": "$aci_fabric",
"present_in_aci_tenant_or_common_id": "$aci_tenant",
},
label=_("ACI VRF"),
)
Tenant-or-common cascade#
For dropdowns whose target might live in the special common tenant
(VRF, BD, Contract, ContractFilter, L3Out), use
present_in_aci_tenant_or_common_id in query_params rather than
aci_tenant_id. The target FilterSet must inherit
ACITenantOrCommonFilterSetMixin (see FilterSets - Mixins
catalog and the
present_in_aci_tenant_or_common_id
filter section).
Single-mental-model relation forms#
When a relation/binding model links two equal-looking parents (e.g.
ACIBridgeDomainL3OutBinding links a BD and an L3Out), pick one
parent as the form's mental model. All scope helpers (aci_fabric,
aci_tenant, aci_vrf) derive from that side via single-key
initial_params. The other side is reached via URL-param injection
from its detail page button (see Templates - Secondary-side
parent-scope injection).
Reference example: ACIBridgeDomainL3OutBindingEditForm chooses
BD as the mental model:
aci_fabric = DynamicModelChoiceField(
queryset=ACIFabric.objects.all(),
initial_params={"aci_tenants__aci_bridge_domains": "$aci_bridge_domain"},
required=False,
label=_("ACI Fabric"),
)
aci_tenant = DynamicModelChoiceField(
queryset=ACITenant.objects.all(),
query_params={"aci_fabric_id": "$aci_fabric"},
initial_params={"aci_bridge_domains": "$aci_bridge_domain"},
required=False,
label=_("ACI Tenant"),
)
aci_vrf = DynamicModelChoiceField(
queryset=ACIVRF.objects.all(),
query_params={"present_in_aci_tenant_or_common_id": "$aci_tenant"},
initial_params={"aci_bridge_domains": "$aci_bridge_domain"},
required=False,
label=_("ACI VRF"),
)
aci_bridge_domain = DynamicModelChoiceField(
queryset=ACIBridgeDomain.objects.all(),
query_params={"aci_tenant_id": "$aci_tenant", "aci_vrf_id": "$aci_vrf"},
label=_("ACI Bridge Domain"),
)
aci_l3out = DynamicModelChoiceField(
queryset=ACIL3Out.objects.all(),
query_params={
"present_in_aci_tenant_or_common_id": "$aci_tenant",
"aci_vrf_id": "$aci_vrf",
},
label=_("ACI L3Out"),
)
All initial_params use the same key (aci_bridge_domains /
aci_tenants__aci_bridge_domains); they all derive from the BD side,
not the L3Out side.
Custom __init__: only for runtime queryset/widget swap#
The declarative initial_params / query_params pattern handles
almost everything. Reach for a custom __init__ only when the
queryset or widget itself must change at runtime based on another
field's value.
Reference example: ACIContractRelationEditForm.__init__ swaps
aci_object.queryset based on the chosen aci_object_type:
def __init__(self, *args, **kwargs) -> None:
"""Initialize the ACI Contract Relation form."""
instance = kwargs.get("instance")
initial = kwargs.get("initial", {}).copy()
if instance is not None and instance.aci_object:
initial["aci_object"] = instance.aci_object
initial["aci_tenant"] = instance.aci_object_tenant
initial["aci_fabric"] = instance.aci_object_tenant.aci_fabric
kwargs["initial"] = initial
super().__init__(*args, **kwargs)
if aci_object_type_id := get_field_value(self, "aci_object_type"):
aci_object_type = ContentType.objects.get(pk=aci_object_type_id)
aci_model = aci_object_type.model_class()
self.fields["aci_object"].queryset = aci_model.objects.all()
self.fields["aci_object"].widget.attrs["selector"] = (
aci_model._meta.label_lower
)
# ...
Don't override __init__ to do cascade work that query_params can
already express.
CSV ImportForm queryset narrowing#
CSV imports arrive with parent FK values as strings (names). The
ImportForm.__init__ narrows child querysets based on what the
incoming row references, so a CSVModelChoiceField resolves to the
right object even when the same child name exists in multiple parents:
def __init__(self, data=None, *args, **kwargs) -> None:
"""Extend import data processing with enhanced query sets."""
super().__init__(data, *args, **kwargs)
if not data:
return
if data.get("aci_fabric") and data.get("aci_tenant"):
self.fields["aci_tenant"].queryset = ACITenant.objects.filter(
aci_fabric__name=data["aci_fabric"]
)
self.fields["aci_app_profile"].queryset = ACIAppProfile.objects.filter(
aci_tenant__aci_fabric__name=data["aci_fabric"],
aci_tenant__name=data["aci_tenant"],
)
Pattern:
super().__init__(data, ...)first; the parent form sets up the declared fields.- Early-out on
if not data: return(the bind-with-no-data case). - Narrow each
CSVModelChoiceField'squerysetby traversing the chain of parent name fields present indata.
This isn't the same as the runtime-queryset-swap pattern above; CSV narrowing operates on the binding data, not on the form's own field values. Keep the two patterns separate.
Form field kwarg ordering#
Pass kwargs to a Django form field in this order. Skip any that aren't needed:
required
widget
label
initial
help_text
error_messages
show_hidden_initial
validators
localize
disabled
label_suffix
For NetBox's DynamicModelChoiceField, the order is:
queryset
query_params
initial_params
null_option
disabled_indicator
context
selector
**kwargs