Skip to content

Models#

Models live under netbox_aci_plugin/models/<domain>/<model>.py. The plugin layers concrete models on top of intermediate abstract bases, almost all rooted at ACIBaseModel(OwnerMixin, NetBoxModel) in models/base.py. The exception is ACIFabric, which extends NetBoxModel directly (bypassing ACIBaseModel) and omits the name_alias field. Shared model behavior, primarily the GFK uniqueness helper, lives in models/mixins.py.

This is the longest layer doc. Use the table of contents to jump:

ACI source-of-truth validation#

Before writing or modifying any model field, validate the field set against both authoritative ACI sources. This is a per-model, every-time requirement - not a one-off review.

Cisco NaC APIC data model (coverage)#

The Cisco Network as Code (NaC) APIC data model documents the curated field set for each ACI MO as used in NaC-based automation. Use it to confirm that the plugin model covers every field an operator would configure and that no relevant attribute is omitted without reason.

Source: https://netascode.cisco.com/docs/data_models/apic/

Find the matching NaC object (e.g. aaep for ACIAttachableAccessEntityProfile, bridge_domain for ACIBridgeDomain) and walk through its attributes. Every NaC attribute should map to a plugin field or carry a documented reason for omission.

Cisco APIC MIM reference (attribute detail)#

The APIC Managed Information Model (MIM) reference specifies the exact attribute constraints for each APIC MO class. Use it to set max_length, validators, default, choices, and blank/null correctly for each field.

Source pattern: https://pubhub.devnetcloud.com/media/apic-mim-ref-<version>/docs/MO-<moClass>.html

Replace <version> with the APIC release being targeted and <moClass> with the MO class name. Examples:

  • ACIAttachableAccessEntityProfile (infraAttEntityP): https://pubhub.devnetcloud.com/media/apic-mim-ref-421e/docs/MO-infraAttEntityP.html
  • ACIBridgeDomain (fvBD): https://pubhub.devnetcloud.com/media/apic-mim-ref-421e/docs/MO-fvBD.html

For each MO attribute, check:

  • Name and alias constraints: max length and allowed characters set max_length and validators on the corresponding model field.
  • Enumerated values: map each to a constant in a ChoiceSet in choices.py (see Choices) and add # default "<value>" above the default member.
  • Default value: use as the field default kwarg.
  • Optionality: attributes the MIM marks as optional map to blank=True, null=True; mandatory attributes map to a required field with no blank=True.

Class hierarchy#

NetBoxModel  (upstream)
  ├─ ACIFabric  (models/fabric/fabrics.py, no ACIBaseModel)
  └─ ACIBaseModel(OwnerMixin, NetBoxModel)  (models/base.py)
      ├─ ACIFabricBaseModel  (models/base.py)
      │   ├─ ACIPod  (concrete)
      │   ├─ ACINode  (concrete)
      │   └─ ACIDomainBaseModel  (models/access_policies/domains.py)
      │       ├─ ACIRoutedDomain  (concrete)
      │       └─ ACIPhysicalDomain  (concrete)
      └─ ACITenantBaseModel  (models/base.py)
          ├─ ACIEndpointGroupBaseModel  (models/tenant/endpoint_groups.py)
          │   ├─ ACIEndpointGroup  (concrete)
          │   └─ ACIUSegEndpointGroup  (concrete)
          ├─ ACIUSegAttributeBaseModel  (models/tenant/endpoint_groups.py)
          │   └─ ACIUSegNetworkAttribute  (concrete)
          └─ <many other concrete tenant-scoped models>

Pick the closest abstract ancestor when defining a new model:

  • A fabric-scoped policy (Routed Domain, Pod, Node): subclass ACIFabricBaseModel.
  • A tenant-scoped policy (VRF, BD, App Profile, Contract): subclass ACITenantBaseModel.
  • An EPG-like model: subclass ACIEndpointGroupBaseModel.
  • A join or relation model with no name of its own: extend NetBoxModel directly (see Relation / Binding models).

ACIBaseModel contributes the universal text fields (name, name_alias, description), nb_tenant, comments, the OwnerMixin fields, and a base clone_fields tuple.

Member ordering#

Inside a model class, order members like this:

database fields
custom manager attributes
class Meta
__str__()
clean_fields()
clean()
save()
delete()
get_absolute_url()
to_objectchange()
@property definitions
custom methods

clean_fields() precedes clean() because Django calls them in that order during full validation (clean_fields -> clean -> validate_unique). Only override clean_fields() when you need to mutate field values before Django's built-in required/type checks run (e.g. ACIExternalSubnet.clean_fields() syncs matched_prefix from nb_prefix before the required-field check fires).

Field ordering#

Place these fields last in every model, in this order, after any domain-specific fields:

nb_tenant
tags
comments

nb_tenant, comments, and the OwnerMixin fields are inherited from ACIBaseModel; don't redeclare them in subclasses unless you need to override behavior.

class Meta#

Annotate types and wrap user-facing strings with _():

class Meta:
    constraints: list[models.UniqueConstraint] = [...]
    ordering: tuple = ("aci_fabric", "name")
    verbose_name: str = _("ACI Tenant")

UniqueConstraint naming template#

Use the %(app_label)s_%(class)s_... template for portability: the template renders to a stable name and inherits cleanly into subclasses:

models.UniqueConstraint(
    fields=("aci_fabric", "name"),
    name="%(app_label)s_%(class)s_unique_name_per_aci_fabric",
)

Migrations referencing constraints by name use the rendered form (e.g. netbox_aci_plugin_acitenant_unique_name).

Conditional UniqueConstraint#

When the constraint should only apply under a condition, use condition=models.Q(...) paired with violation_error_message=_("...") for user-readable validation feedback at the database level:

models.UniqueConstraint(
    fields=(
        "aci_useg_endpoint_group",
        "use_epg_subnet",
    ),
    name=(
        "%(app_label)s_%(class)s_unique_use_epg_subnet_"
        "per_useg_endpoint_group"
    ),
    condition=models.Q(use_epg_subnet=True),
    violation_error_message=_(
        "ACI uSeg Endpoint Group with a 'use EPG Subnet' "
        "attribute already exists."
    ),
),

Good examples: ACIBridgeDomainSubnet (bridge_domains.py) and ACINode (nodes.py).

clone_fields and prerequisite_models#

Every concrete model carries both - declared directly or inherited from its abstract base (for example, ACIEndpointGroupBaseModel in endpoint_groups.py declares prerequisite_models for all its subclasses, and ACIDomainBaseModel in access_policies/domains.py declares clone_fields). The one exception is ACIFabric: as the root of the fabric hierarchy it has no prerequisite_models.

clone_fields: tuple = ACITenantBaseModel.clone_fields + (
    "aci_tenant",
    "qos_class",
)
prerequisite_models: tuple = ("netbox_aci_plugin.ACITenant",)

Inherit from the parent's clone_fields rather than restating its entries; drift accumulates fast when bases evolve.

Hierarchy navigation#

Every concrete model exposes a parent_object @property and any useful cross-tier shortcuts:

@property
def aci_fabric(self) -> ACIFabric:
    return self.aci_tenant.aci_fabric

@property
def parent_object(self) -> ACITenant:
    return self.aci_tenant

parent_object is what to_objectchange() and the URL/breadcrumb machinery consult for the "what owns this?" relationship. ACITenantBaseModel already provides aci_fabric via self.aci_tenant.aci_fabric, so tenant-scoped models inherit it for free.

Which owner shortcuts a model exposes follows its place in the ACI hierarchy. A tenant-scoped object can reach both its tenant and its fabric, so it exposes aci_tenant and aci_fabric. A fabric-scoped or access-policy object sits outside any tenant, so it exposes only aci_fabric.

An association model reaches its owners through the objects it links, and names each shortcut after the owner it actually reaches. Reaching a single tenant, it exposes aci_tenant. Able to reach two different tenants, it names each one, as ACIContractRelation does with aci_contract_tenant and aci_object_tenant. The fabric follows the same idea, and an association whose sides are both access-policy objects has no tenant to expose.

Keep the declaration order stable: the owner and cross-tier shortcuts come first, parent_object after them, and any computed value property last. An association model lists its owner shortcuts in hierarchy order, outermost owner first. A computed property, such as an effective_* encapsulation resolver or ACIExternalSubnet's prefix_source, returns a derived value rather than a related object, so it belongs after the owner shortcuts and parent_object, never interleaved among them.

Choice color helpers#

For every ChoiceSet-backed field, declare a get_<field>_color() method that proxies to the ChoiceSet's color map:

def get_qos_class_color(self) -> str:
    """Return the associated color of choice from the ChoiceSet."""
    return QualityOfServiceClassChoices.colors.get(self.qos_class)

Tables and templates consume get_<field>_color() to render colored badges (see Tables - ChoiceFieldColumn and Templates - Cell helpers).

clean()#

Default rule: call super().clean() first and accumulate all field-keyed errors into a dict before raising once. Never raise on the first error:

def clean(self) -> None:
    super().clean()
    errors = {}
    if condition1:
        errors.setdefault("field1", []).append(_("message"))
    if condition2:
        errors.setdefault("field2", []).append(_("message"))
    if errors:
        raise ValidationError(errors)

This lets the form layer show every error at once, rather than surfacing one error, then another only after the user resubmits.

GFK early-guard exception: GFK-bearing models may raise a field-keyed ValidationError before super().clean() when a *_type FK is set but its companion object ID is absent. This prevents Django's built-in validation from choking on a partially populated GFK pair. After that guard, still call super().clean() and accumulate the remaining validation errors as above. See ACIContractRelation.clean() (contracts.py), ACIUSegNetworkAttribute.clean() (endpoint_groups.py), ACINode.clean() (nodes.py), the two ACIEsgEndpoint*Selector.clean() methods (endpoint_security_groups.py), and ACIAAEPDomainBinding.clean() (aaep.py) for examples.

Parent-FK _id guard: before dereferencing any parent relation inside clean() (e.g. self.aci_tenant.aci_fabric_id), guard on the FK's _id attname rather than the relation attribute itself (if self.aci_vrf_id and self.aci_tenant_id:). A partial form submit can leave a required FK unset, and dereferencing it directly raises RelatedObjectDoesNotExist, surfacing as an HTTP 500 during full_clean() instead of a validation error. See ACIL3Out.clean() (l3outs.py), ACIEndpointGroupBaseModel.clean() (endpoint_groups.py), and ACIEndpointSecurityGroup.clean() (endpoint_security_groups.py) for examples.

to_objectchange()#

Set related_object so the audit log links the change to the parent's history page:

def to_objectchange(self, action) -> ObjectChange:
    objectchange = super().to_objectchange(action)
    objectchange.related_object = self.aci_contract
    return objectchange

Pick the parent that makes the most sense for an audit reader (often the same object as parent_object).

save() and alters_data#

When the save path runs side-effecting helpers (e.g. denormalized FK caching), mark each helper with alters_data so Django's template engine refuses to call them implicitly:

def save(self, *args, **kwargs) -> None:
    self.cache_related_objects()
    super().save(*args, **kwargs)

def cache_related_objects(self) -> None:
    ...

cache_related_objects.alters_data = True

Denormalized FK caching#

GFK-bearing models (ACIContractRelation, ACIUSegNetworkAttribute, ACINode, ACIEsgEndpointGroupSelector, ACIEsgEndpointSelector, ACIAAEPDomainBinding) cache each possible concrete target in an _-prefixed FK field. The cache lets search, filter ordering, and table querysets use concrete FK fields instead of traversing the GFK at query time:

# Cached related objects by association name for faster access
_aci_endpoint_group = models.ForeignKey(
    to="netbox_aci_plugin.ACIEndpointGroup",
    on_delete=models.CASCADE,
    related_name="_aci_contract_relations",
    verbose_name=_("ACI Endpoint Group"),
    blank=True,
    null=True,
)
_aci_endpoint_security_group = models.ForeignKey(
    to="netbox_aci_plugin.ACIEndpointSecurityGroup",
    # ...
)
_aci_useg_endpoint_group = models.ForeignKey(...)
_aci_external_endpoint_group = models.ForeignKey(...)
_aci_vrf = models.ForeignKey(...)

Rules:

  • One _-prefixed FK per possible target type.
  • on_delete=models.CASCADE, blank=True, null=True.
  • related_name uses the same _<relation_name> shape on each target (_aci_contract_relations).
  • Populated from save() via a cache_related_objects() helper marked with alters_data (see save() and alters_data).
  • Excluded from GraphQL types via exclude=[...] (see GraphQL - Types).
  • Referenced by name in search.py weight tuples when the related object should be searchable (see Search - Denormalized FK fields).

Generic Foreign Key pattern#

Three parts, in this order:

1. Content-type filter in constants.py#

CONTRACT_RELATION_OBJECT_TYPES = Q(
    app_label="netbox_aci_plugin",
    model__in=(
        "aciendpointgroup",
        "aciendpointsecuritygroup",
        "aciexternalendpointgroup",
        "aciusegendpointgroup",
        "acivrf",
    ),
)

See Validators & Constants - Q-object content-type filters for naming.

2. GFK trio on the model#

The mandatory suffix is <name>_type (Django's content-type FK), plus the companion <name>_id and the <name> = GenericForeignKey(...). NetBox-style (scope_type/scope_id/scope) and ACI-style (aci_object_type/aci_object_id/aci_object) are both acceptable; the hard requirement is the _type suffix:

aci_object_type = models.ForeignKey(
    to="contenttypes.ContentType",
    on_delete=models.PROTECT,
    related_name="+",
    limit_choices_to=CONTRACT_RELATION_OBJECT_TYPES,
)
aci_object_id = models.PositiveBigIntegerField()
aci_object = GenericForeignKey(
    ct_field="aci_object_type",
    fk_field="aci_object_id",
)

3. UniqueGenericForeignKeyMixin#

Apply UniqueGenericForeignKeyMixin from models/mixins.py and declare generic_fk_field + generic_unique_fields. Call self._validate_generic_uniqueness() from clean():

class ACIContractRelation(NetBoxModel, UniqueGenericForeignKeyMixin):
    generic_fk_field: str = "aci_object"
    generic_unique_fields: tuple[str] = ("aci_contract", "role")

    def clean(self) -> None:
        super().clean()
        self._validate_generic_uniqueness()
        # ...

The mixin raises a ValidationError with the verbose names of the conflicting target model + the additional unique fields.

OwnerMixin coverage#

OwnerMixin is the user-attribution mixin from users.models. It's applied at three layers for primary models:

  • Model: class ACIBaseModel(OwnerMixin, NetBoxModel) (inherited).
  • Serializer: class <Model>Serializer(OwnerMixin, NetBoxModelSerializer) (see REST API - Inheritance).
  • GraphQL type: class <Model>Type(OwnerMixin, NetBoxObjectType) (see GraphQL - Types).

Relation / binding models (ACIBridgeDomainL3OutBinding, ACIContractRelation) skip OwnerMixin at every layer; they extend NetBoxModel / NetBoxModelSerializer / NetBoxObjectType directly. Relations have no independent identity worth attributing to an owner.

Relation / Binding models#

Use Binding for explicit ACI attachment/deployment associations where one policy object is bound to another operational target, such as a domain, path, L3Out, or AAEP domain. Binding models may carry configuration attributes such as deployment immediacy, resolution immediacy, VLAN encapsulation, mode, or VMM-specific settings. These attributes parameterize the binding and do not make the model a Relation.

Use Relation for semantic policy relationships where a role, direction, or type discriminator changes the meaning of the relationship itself. For example, ACIContractRelation.role distinguishes provider and consumer semantics.

Use more specific ACI/domain nouns such as Selector, Attribute, or Filter when those names better describe the modeled concept.

Parent placement#

Relation / Binding classes live in the parent's model file (the side that owns parent_object). Example: ACIBridgeDomainL3OutBinding lives in bridge_domains.py because parent_object = aci_bridge_domain. Rationale:

  • Cisco's MIT containment nests <fvRsBDToOut> inside <fvBD>.
  • Network as Code models l3outs as a field of bridge_domains.
  • The codebase rule is policy containment, not "every model that references X lives in X's file", as proven by ACIEndpointGroupBaseModel (FK to BD, lives in endpoint_groups.py).

The relation's table, filterset, form, serializer, and GraphQL filter also live in the parent's layer file.

Use the aci_ prefix on related_name to reduce overlap with NetBox-side reverse relations and to keep the namespace self-documenting at the call site:

aci_bridge_domain = models.ForeignKey(
    to="netbox_aci_plugin.ACIBridgeDomain",
    on_delete=models.CASCADE,
    related_name="aci_l3out_bindings",
    verbose_name=_("ACI Bridge Domain"),
)

Inheritance#

Relation/Binding models extend NetBoxModel directly, not ACIBaseModel. They have no name field and no DN-like identity, so the ACI-policy text fields don't apply. See OwnerMixin coverage for the matching skip at serializer + GraphQL-type layers.

ACICachedScopeMixin#

Fabric-scoped models (ACIFabric, ACIPod) inherit ACICachedScopeMixin from models/mixins.py, which adds scope_type / scope_id / scope for assignment to a Site / Region / SiteGroup / Location. The plugin mixin subclasses NetBox's dcim.models.mixins.CachedScopeMixin and pins the _region and _site_group cache fields to on_delete=SET_NULL, adopting the upstream fix for cache fields cascading on ancestor deletion (NetBox issue #22682) ahead of its release. The explicit declarations keep the plugin's migration state identical on every supported NetBox version; drop the overrides and inherit the NetBox mixin directly once the minimum supported NetBox release ships that fix. Include the scope fields in clone_fields:

from ..mixins import ACICachedScopeMixin


class ACIFabric(ACICachedScopeMixin, OwnerMixin, NetBoxModel):
    # ...
    clone_fields: tuple = (
        "description",
        "infra_vlan_vid",
        "infra_vlan",
        "gipo_pool",
        "scope_type",
        "scope_id",
        "nb_tenant",
    )

CachedScopeMixin ships its own denormalized cache fields (_region, _site_group, _site, _location); exclude these from GraphQL output via exclude=[...] on @strawberry_django.type.

Choices#

All ChoiceSet subclasses live in netbox_aci_plugin/choices.py, grouped by domain with section comments:

# Bridge Domain

class BDMultiDestinationFloodingChoices(ChoiceSet):
    """Choice set of Bridge Domain multi destination flooding."""

    # default "bd-flood"
    FLOOD_BD = "bd-flood"
    FLOOD_ENCAP = "encap-flood"
    FLOOD_DROP = "drop"

    CHOICES = (
        (FLOOD_BD, _("bd-flood"), "blue"),
        (FLOOD_ENCAP, _("encap-flood"), "yellow"),
        (FLOOD_DROP, _("drop"), "red"),
    )

Conventions:

  • Name pattern: <Domain><Field>Choices.
  • Constants are class attributes in UPPER_SNAKE_CASE with a domain prefix (FLOOD_BD, UNKNOWN_MULTI_FLOOD).
  • Add a # default "<value>" comment so contributors see the model's field default at a glance.
  • The third tuple element is the badge color (NetBox table/template helpers consume it via get_<field>_color(), see Choice color helpers).

add_custom_choice()#

For ChoiceSets that allow a free-text value alongside the enumerated choices, append (None, _("custom")) via the helper:

def add_custom_choice(choices) -> tuple:
    """Add a custom choice to the end of a ChoiceSet."""
    return tuple(choices) + ((None, _("custom")),)

Use it where a field accepts both a named choice and an arbitrary string (e.g. some Contract Filter port fields).

Cross-cutting choice sets#

Choice sets used by multiple domains (e.g. QualityOfServiceClassChoices, QualityOfServiceDSCPChoices) get their own conceptual section in choices.py. Put them after the domain sections that reference them.

ACI concept casing in prose#

When prose names the ACI concept that a model or field represents, in a class docstring, a Notes: block, a validation or error message, or a help_text sentence, Title-Case the concept noun: "a Bridge Domain", "the Endpoint Group", "a Contract Filter". This matches how the same noun is already capitalized inside that model's verbose_name=_("ACI <X>") value. A generic English use of the same word, one that isn't naming the ACI concept, stays lowercase:

_("A Bridge Domain must have at least one gateway subnet.")

This rule is scoped to prose casing only; casing inside verbose_name values and code comments follows a separate, already-settled convention.

Model field kwarg ordering#

Pass kwargs to model fields in this order. Skip any that aren't needed; don't reorder:

Base (every field)#

verbose_name
name
primary_key
max_length
unique
blank
null
db_index
rel
default
editable
serialize
unique_for_date
unique_for_month
unique_for_year
choices
help_text
db_column
db_tablespace
auto_created
validators
error_messages

DateField / TimeField (append after base)#

auto_now
auto_now_add

DecimalField (append after base)#

max_digits
decimal_places

GenericIPAddressField (append after base)#

protocol
unpack_ipv4

ForeignKey#

For ForeignKey fields, use this standalone order, with to first, before the base kwargs:

to
on_delete
related_name
verbose_name
blank
null
related_query_name
limit_choices_to
parent_link
to_field
db_constraint

GFK content-type FK exception: the <name>_type FKs that pair with a GenericForeignKey (e.g. aci_object_type in contracts.py, aci_domain_object_type in aaep.py) place limit_choices_to right after related_name, ahead of verbose_name/blank/null, so the Q-object content-type filter sits next to the relation it constrains.

ManyToManyField (append after base)#

symmetrical
through
through_fields
db_table
swappable

FileField (append after base)#

upload_to
storage