Migrations#
Migration files live in netbox_aci_plugin/migrations/. The plugin
mixes Django-generated schema migrations with hand-authored data
migrations for seed content.
Filename pattern#
NNNN_<short_description>.py where the description names the grouped
feature, not individual model names. The grouping makes the migration
list legible at a glance:
Examples:
0001_initial.py: initial schema and seed tenants.0011_fabric.py: ACIFabric model and default fabric.0012_fabric_pod.py: ACIPod model.0014_owner.py:ownerFK rollout across many models.0017_tenant_l3outs.py: L3Out, ExternalEndpointGroup, and ExternalSubnet.0018_bridge_domain_l3out_binding.py: binding relation.
Prefer one descriptive name over a long string of model names. If a
single migration touches many models because they share a feature
(e.g. owner), name it after the feature.
License header#
Migration files carry no SPDX license header. Leaving it off is a deliberate exception to the general rule that every non-empty Python source file starts with one. Migrations begin as generated files and keep the no-header convention even when later hand-edited, so the missing header is intentional and should not be backfilled.
Data migration idempotency#
Every seed-data RunPython operation must be idempotent: running
the same migration twice on the same database must produce the same
result. Use this two-part contract:
- Gate on the plugin config flag via
get_plugin_config(...). - Guard each create with
.filter(...).exists().
from netbox.plugins.utils import get_plugin_config
from netbox_aci_plugin import ACIConfig
def create_default_aci_tenants(apps, schema_editor) -> None:
"""Creates default ACI tenants if they do not already exist."""
if get_plugin_config(ACIConfig.name, "create_default_aci_tenants", True):
db_alias = schema_editor.connection.alias
aci_tenant = apps.get_model(ACIConfig.name, "ACITenant")
default_aci_tenants = ["common", "infra", "mgmt"]
for default_aci_tenant in default_aci_tenants:
if (
not aci_tenant.objects.using(db_alias)
.filter(name=default_aci_tenant)
.exists()
):
aci_tenant.objects.using(db_alias).create(name=default_aci_tenant)
Always use apps.get_model() inside RunPython
Importing the concrete model class freezes it to its current
definition. If the model evolves later, an old migration that
imports the class breaks. apps.get_model("netbox_aci_plugin",
"ACITenant") returns the historical model as it existed at the
migration's place in history.
Reverse handler#
Data migrations that add seed rows pass migrations.RunPython.noop as
the reverse: the seed rows are owned by the user once created, and
auto-deleting them on rollback is unsafe:
migrations.RunPython(create_default_aci_fabric, migrations.RunPython.noop),
When the reverse genuinely cannot be expressed (e.g. ambiguous undo), omit it and document the reason in a comment.
Owner rollout pattern#
0014_owner.py is the reference shape for adding one field across many
models in a single migration. Use this pattern when introducing
cross-cutting fields like owner, status, or future mixin fields:
class Migration(migrations.Migration):
dependencies = [
("netbox_aci_plugin", "0013_fabric_node"),
("users", "0015_owner"),
]
operations = [
migrations.AddField(
model_name="aciappprofile",
name="owner",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="users.owner",
),
),
migrations.AddField(
model_name="acibridgedomain",
name="owner",
field=models.ForeignKey(...),
),
# ... one AddField per model
]
Rules:
- Each model gets its own
AddFieldop; don't try to share the field definition object across ops. Django's autodetector emits them separately and the migration is more readable that way. - Field options are uniform:
blank=True,null=True,on_delete=PROTECT. Optional ownership should never block deletion upstream. - One dependency entry per app whose model the FK targets (here,
users).
UniqueConstraint.name template#
Every UniqueConstraint declared in a model uses the
%(app_label)s_%(class)s_... name template (see Models -
UniqueConstraint
naming) so the constraint
name remains stable when models are renamed and inherits cleanly into
subclasses. Migrations referencing constraints by name (e.g.
RemoveConstraint ops) must use the rendered name, e.g.
netbox_aci_plugin_acitenant_unique_name.
Schema migrations#
Prefer auto-generated migrations from python manage.py makemigrations
netbox_aci_plugin. Hand-edit only for:
- Adjusting
dependenciesafter a manual rebase. - Adding a
RunPythonop alongside the schema change. - Renaming an op for clarity (rare).
Don't hand-edit the field-definition argument list; let Django emit it. If a difference between the model and the generated migration surprises you, the model is probably what needs adjusting, not the migration.