Skip to content

Data model

The whole shape on one page. Boxes are models, arrows are FKs.

Organization                                 [Tag] (global today; per-tenant Phase 5)
  │
  ▼
Tenant ──── hard isolation boundary ──────┐
  ├─ sites    : Site[]                    │
  ├─ vrfs     : VRF[]                     │
  ├─ vlans    : VLAN[]                    │
  ├─ device_types : DeviceType[]          │  every domain model
  ├─ devices  : Device[]                  │  carries  tenant FK
  ├─ prefixes : Prefix[]                  │
  ├─ ip_addresses : IPAddress[]           │
  └─ cables   : Cable[]                   │
                                           │
VRF (tenant-scoped)                        │
  └─ prefixes : Prefix[]                   │
  └─ ip_addresses : IPAddress[]            │
  └─ sites    : Site[]   (M2M, docs only)  │
                                           │
Site (tenant-scoped, location)             │
  ├─ name                                  │
  ├─ gateway_policy : first | last | none  │
  └─ vrfs   : VRF[]   (M2M, docs only)     │
                                           │
Prefix (tenant + vrf scoped)               │
  ├─ cidr                                  │
  ├─ status : container | active | reserved | deprecated
  ├─ site → Site                           │
  ├─ vlan → VLAN                           │
  ├─ vrf  → VRF | NULL (Global)            │
  ├─ gateway : IP string                   │
  ├─ custom_fields : JSONB                 │
  ├─ tags : Tag[]   (via TaggedItem)       │
  └─ ip_addresses : IPAddress[]            │
                                           │
IPAddress (tenant + vrf scoped)            │
  ├─ ip_address                            │
  ├─ status : available | assigned | reserved | dhcp_pool | floating
  ├─ role   : '' | gateway | loopback | vip | hsrp | vrrp | anycast | secondary
  ├─ scope  : public | private | cgnat | special   (read-only, derived from the address)
  ├─ prefix → Prefix                       │
  └─ vrf    → VRF | NULL                   ┘

Read-only derived API fields

The serialized IPAddress.scope (above) and DeviceType.manufacturer (the manufacturer's name, echoed on the nested device-type) are read-only convenience fields - they back list filters (the IP scope facet, the device manufacturer facet) and carry no schema/migration of their own.

Mixins, by which every domain model gets ...

class TimestampedModel(Model):
    created_at = DateTimeField(auto_now_add=True)
    updated_at = DateTimeField(auto_now=True)
    class Meta: abstract = True

class CustomFieldsMixin(Model):
    custom_fields = JSONField(default=dict, blank=True)
    class Meta: abstract = True

class TaggableMixin(Model):
    tags = TaggableManager(blank=True, through=TaggedItem)
    class Meta: abstract = True

Prefix, IPAddress, Site, DeviceType, Device, VLAN, Cable all multi-inherit these three.

Custom Tag with color

core.Tag subclasses taggit's TagBase to add color (hex string). The TaggedItem through-model uses GenericUUIDTaggedItemBase because all our content models have UUID PKs (the default IntegerField object_id overflows on UUID values).

Uniqueness constraints

Model Unique on
Tenant (org, slug) and (org, name)
Site (tenant, name)
VRF (tenant, name)
VLAN (tenant, vlan_id)
Prefix (tenant, vrf, cidr) with nulls_distinct=False ← critical
IPAddress (tenant, vrf, ip_address) with nulls_distinct=False
DeviceType (tenant, name)
Device (tenant, name)

Natural name order

Names that carry numbers sort the way people read them - DIMM 2 before DIMM 10, Ethernet1/2 before Ethernet1/10, R2 before R10 - never the plain 1, 10, 11, 2 of a byte compare. In PostgreSQL that is the natural_sort ICU collation (und-u-kn-true, migration api/0099); api.natural wraps it:

Helper Use
natural("name") Collate(field, "natural_sort") for order_by and Meta.ordering; works across relations (natural("device__name")) and reverses with .desc(). NATURAL_NAME in api/viewsets.py is natural("name").
natural_key(value) A Python sort key for a list already in memory (sorted(rows, key=lambda r: natural_key(r["name"]))).

Every device and VM component (interfaces, front/rear/console/power ports, outlets, bays, modules, inventory items, antennas, VM interfaces, virtual disks), every component template, and racks and locations (site, then name) carry a natural Meta.ordering, so a related list such as device.interfaces.all() - serializer nesting, spec sheets, config rendering - is in that order without an explicit order_by. List endpoints order the same way, including the device name that leads a cross-device component list. In the UI, naturalCompare (frontend/src/lib/natural-sort.ts) is the same order for client-side sorts, and every DataTable column that sets no sortingFn of its own sorts its text with it. Its collator is pinned to en (root order, as und is on the server): the browser's own locale would reorder names per language - Danish sorts Aalborg after Z.

After upgrading

Rendered configs and the routing config context list interfaces in natural order, including those under FHRP, BGP, OSPF, IS-IS, EIGRP and LDP. The first render after upgrading from an earlier version can differ from a stored bundle or a device's running config by line order alone.

Conventional VRF = NULL

We don't seed a "Global" VRF row. vrf=NULL is the Global VRF - that's why nulls_distinct=False is load-bearing. See Tenant + VRF for the full reasoning.