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
NetBox Tenancy, and listtagsas the last entry of the final domain FieldSet rather than giving it a FieldSet of its own. Do not add an Ownership or Comments FieldSet.tagsis not auto-rendered here, unlike on a BulkEditForm, so it does have to appear somewhere. - 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.
ChoiceField and MultipleChoiceField come from
utilities.forms.fields, not from django.forms. Only the NetBox
classes render the description a Choice carries as an option subtitle
(see Models - Choices).
# EditForm - required, no blank entry
target_dscp = ChoiceField(
choices=QualityOfServiceDSCPChoices,
initial=QualityOfServiceDSCPChoices.DSCP_UNSPECIFIED,
label=_("Target DSCP"),
)
# BulkEditForm - optional, blank means "leave unchanged"
target_dscp = 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).
Range pairs render inline#
A <field>_from / <field>_to pair listed as two positional names
renders as two stacked rows, which reads as two unrelated inputs. Wrap
the pair in InlineFields so it renders side by side under one label,
and give it help_text explaining what the range covers. Derive the
bounds from the same constants the model validators use, so the text
cannot drift:
from utilities.forms.rendering import FieldSet, InlineFields
FieldSet(
InlineFields(
"vlan_id_from",
"vlan_id_to",
label=_("VLAN IDs"),
help_text=_("First and last VLAN ID of the range, from {min} to {max}.").format(
min=VLAN_VID_MIN, max=VLAN_VID_MAX
),
),
"allocation_mode",
"role",
name=_("Encapsulation Block"),
)
This applies to edit forms only. On a FilterForm the same two names
are independent exact-match filters over each column, not the ends of
one range, so presenting them as a range would misrepresent what they
do. tests/forms/test_conventions.py enforces both halves: no stacked
pair on an edit form, and no InlineFields without a label and help
text.
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 UI - 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 initial values nothing else supplies#
The declarative initial_params / query_params pattern handles
almost everything, and GenericObjectChoiceField handles the runtime
queryset swap for a generic foreign key (see
Generic foreign keys below). Reach for a
custom __init__ only to seed an initial value that neither can
derive.
Reference example: ACIContractRelationEditForm.__init__ pre-fills the
helper aci_fabric and aci_tenant dropdowns from the bound object,
which the generic object field knows nothing about:
def __init__(self, *args, **kwargs) -> None:
"""Initialize the ACI Contract Relation form."""
instance = kwargs.get("instance")
initial = kwargs.get("initial", {}).copy()
# Seed the helper dropdowns from the OBJECT tenant, not the contract
# tenant. A contract held in "common" must still filter the object
# dropdown by its own tenant, and the contract dropdown must offer
# both the object's tenant and common.
if instance is not None and 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)
Don't override __init__ to do cascade work that query_params can
already express, and don't re-implement the content-type-to-queryset
dance that GenericObjectChoiceField performs.
Generic foreign keys#
A model with a GenericForeignKey gets one form field, not a
content-type plus object pair. Combine GenericObjectFormMixin with
GenericObjectChoiceField, and feed the field the same Q object from
constants.py that bounds the model side:
class ACIContractRelationEditForm(GenericObjectFormMixin, NetBoxModelForm):
aci_object = GenericObjectChoiceField(
content_type_queryset=ContentType.objects.filter(
CONTRACT_RELATION_OBJECT_TYPES
),
query_params={
"aci_fabric_id": "$aci_fabric",
"aci_tenant_id": "$aci_tenant",
},
selector=True,
hx_target_id="aci_object",
label=_("ACI Object"),
)
The mixin seeds the field from the GFK descriptor and assigns the
cleaned object back to it, so neither a custom __init__ nor a
clean() is needed for the assignment.
Three rules that are easy to miss:
hx_target_idneeds a matchingFieldSet(html_id=...). Without it the partial swap has no container and silently does nothing. NetBox warns about this underDEBUG. A form with two generic object fields therefore needs two field sets, one per field.- Bulk edit forms take
hx_method="post"and nohx_target_id, which re-renders the whole form. This mirrors NetBox's ownScopedBulkEditForm. - The rendered input names are
<field>_content_typeand<field>_object_id, not the field name. Form tests, view testform_data, and anyurl_paramsthat pre-fill the field through a parent object's Add button all use those names.
Filter forms and import forms are unaffected: they keep a plain
ContentTypeChoiceField or CSVContentTypeField alongside an object
ID, because neither renders the paired selector.
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