Add netbox_rack_concept plugin: rack blueprint planning
Introduces a NetBox 4.6 plugin for planning rack layouts (RackConcept, ConceptDevice) without creating real dcim.Rack/dcim.Device objects, so blueprints never appear in rack/device lists, utilization reports, or IPAM. Features: - Copy paths: real rack -> concept, concept -> concept (clone), and concept -> one or more real racks (deploy, with name patterning and dry-run support) - Manual concept creation via a dedicated "Rack Concepts" nav menu - Tenant and tenant group assignment on concepts and planned devices - Partial-width device placement (1/1-1/4) with slot-based collision detection, matching netbox_utilities' partial-width feature - Custom drag-and-drop elevation (netbox-reorder-rack operates on real Device querysets, which concepts don't have) with server-side validation of every move - Full REST API, filtering, CSV import, bulk edit, search integration - Optional, config-driven interop with netbox_utilities (partial-width field mapping) and netbox-reorder-rack, degrading gracefully when either plugin is absent Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import (
|
||||
DeviceFaceChoices, RackAirflowChoices, RackDimensionUnitChoices, RackFormFactorChoices, RackWidthChoices,
|
||||
)
|
||||
from netbox.models import NetBoxModel
|
||||
|
||||
from .choices import LANE_COUNT, ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices
|
||||
|
||||
__all__ = (
|
||||
'ConceptDevice',
|
||||
'ConceptDeployment',
|
||||
'RackConcept',
|
||||
)
|
||||
|
||||
|
||||
class RackConcept(NetBoxModel):
|
||||
"""
|
||||
A rack blueprint. It mirrors the geometry of dcim.Rack but is a separate model on
|
||||
purpose: a concept must never show up in the rack list, in rack elevations or in
|
||||
utilisation reports. It becomes a real rack only when it is deployed.
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
status = models.CharField(
|
||||
verbose_name=_('status'),
|
||||
max_length=50,
|
||||
choices=ConceptStatusChoices,
|
||||
default=ConceptStatusChoices.STATUS_DRAFT
|
||||
)
|
||||
tenant_group = models.ForeignKey(
|
||||
to='tenancy.TenantGroup',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='rack_concepts',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='rack_concepts',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
# Rack geometry, mirroring dcim.RackBase
|
||||
form_factor = models.CharField(
|
||||
verbose_name=_('form factor'),
|
||||
choices=RackFormFactorChoices,
|
||||
max_length=50,
|
||||
blank=True
|
||||
)
|
||||
rack_type = models.ForeignKey(
|
||||
to='dcim.RackType',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='rack_concepts',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Copy the geometry from a rack type instead of defining it here')
|
||||
)
|
||||
width = models.PositiveSmallIntegerField(
|
||||
verbose_name=_('width'),
|
||||
choices=RackWidthChoices,
|
||||
default=RackWidthChoices.WIDTH_19IN
|
||||
)
|
||||
u_height = models.PositiveSmallIntegerField(
|
||||
verbose_name=_('height (U)'),
|
||||
default=42,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(100)]
|
||||
)
|
||||
starting_unit = models.PositiveSmallIntegerField(
|
||||
verbose_name=_('starting unit'),
|
||||
default=1,
|
||||
validators=[MinValueValidator(1)]
|
||||
)
|
||||
desc_units = models.BooleanField(
|
||||
verbose_name=_('descending units'),
|
||||
default=False
|
||||
)
|
||||
outer_width = models.PositiveSmallIntegerField(blank=True, null=True)
|
||||
outer_height = models.PositiveSmallIntegerField(blank=True, null=True)
|
||||
outer_depth = models.PositiveSmallIntegerField(blank=True, null=True)
|
||||
outer_unit = models.CharField(
|
||||
max_length=50,
|
||||
choices=RackDimensionUnitChoices,
|
||||
blank=True
|
||||
)
|
||||
mounting_depth = models.PositiveSmallIntegerField(blank=True, null=True)
|
||||
airflow = models.CharField(
|
||||
max_length=50,
|
||||
choices=RackAirflowChoices,
|
||||
blank=True
|
||||
)
|
||||
max_weight = models.PositiveIntegerField(blank=True, null=True)
|
||||
|
||||
# Defaults applied when the concept is deployed
|
||||
default_site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Pre-selected when deploying this concept')
|
||||
)
|
||||
default_location = models.ForeignKey(
|
||||
to='dcim.Location',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
rack_role = models.ForeignKey(
|
||||
to='dcim.RackRole',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
# Provenance
|
||||
source_rack = models.ForeignKey(
|
||||
to='dcim.Rack',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('The rack this concept was copied from')
|
||||
)
|
||||
|
||||
description = models.CharField(max_length=200, blank=True)
|
||||
comments = models.TextField(blank=True)
|
||||
|
||||
clone_fields = (
|
||||
'status', 'tenant_group', 'tenant', 'form_factor', 'rack_type', 'width', 'u_height', 'starting_unit',
|
||||
'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow',
|
||||
'max_weight', 'default_site', 'default_location', 'rack_role', 'description',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('rack concept')
|
||||
verbose_name_plural = _('rack concepts')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('plugins:netbox_rack_concept:rackconcept', args=[self.pk])
|
||||
|
||||
def get_status_color(self):
|
||||
return ConceptStatusChoices.colors.get(self.status)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.tenant and self.tenant_group and self.tenant.group_id != self.tenant_group_id:
|
||||
raise ValidationError({
|
||||
'tenant': _('Tenant {tenant} does not belong to group {group}.').format(
|
||||
tenant=self.tenant, group=self.tenant_group
|
||||
)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Keep the group in sync so tenant-group filtering works without a join.
|
||||
if self.tenant and not self.tenant_group:
|
||||
self.tenant_group = self.tenant.group
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def units(self):
|
||||
"""Unit numbers ordered as they are displayed, top first."""
|
||||
units = range(self.starting_unit, self.starting_unit + self.u_height)
|
||||
return list(units) if self.desc_units else list(reversed(units))
|
||||
|
||||
@property
|
||||
def unit_range(self):
|
||||
"""(lowest, highest_exclusive) unit numbers as Decimals."""
|
||||
return Decimal(self.starting_unit), Decimal(self.starting_unit + self.u_height)
|
||||
|
||||
@property
|
||||
def total_u_used(self):
|
||||
return sum(d.effective_u_height for d in self.devices.all())
|
||||
|
||||
@property
|
||||
def utilization(self):
|
||||
if not self.u_height:
|
||||
return 0
|
||||
return min(int(self.total_u_used / Decimal(self.u_height) * 100), 100)
|
||||
|
||||
|
||||
class ConceptDevice(NetBoxModel):
|
||||
"""
|
||||
A planned device inside a blueprint. It references a real dcim.DeviceType so that
|
||||
height, depth and manufacturer stay accurate, but it is never a dcim.Device and so
|
||||
never appears in the device list, in interface counts or in IPAM.
|
||||
"""
|
||||
concept = models.ForeignKey(
|
||||
to=RackConcept,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='devices'
|
||||
)
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=64,
|
||||
blank=True,
|
||||
help_text=_('Optional. Supports the placeholder {n} when deploying multiple racks.')
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=ConceptDeviceStatusChoices,
|
||||
default=ConceptDeviceStatusChoices.STATUS_PLANNED
|
||||
)
|
||||
device_type = models.ForeignKey(
|
||||
to='dcim.DeviceType',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='concept_devices',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Leave empty to reserve space without committing to a model')
|
||||
)
|
||||
role = models.ForeignKey(
|
||||
to='dcim.DeviceRole',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='concept_devices',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
platform = models.ForeignKey(
|
||||
to='dcim.Platform',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='concept_devices',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='concept_devices',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Overrides the tenant of the concept')
|
||||
)
|
||||
|
||||
# Placement
|
||||
position = models.DecimalField(
|
||||
verbose_name=_('position (U)'),
|
||||
max_digits=4,
|
||||
decimal_places=1,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(Decimal('0.5'))],
|
||||
help_text=_('Lowest unit occupied by the device. Empty means unracked.')
|
||||
)
|
||||
face = models.CharField(
|
||||
verbose_name=_('rack face'),
|
||||
max_length=50,
|
||||
choices=DeviceFaceChoices,
|
||||
default=DeviceFaceChoices.FACE_FRONT
|
||||
)
|
||||
width_fraction = models.PositiveSmallIntegerField(
|
||||
verbose_name=_('width'),
|
||||
choices=WidthFractionChoices,
|
||||
default=WidthFractionChoices.FRACTION_FULL,
|
||||
help_text=_('Horizontal share of the rack unit, as used by netbox_utilities')
|
||||
)
|
||||
horizontal_position = models.PositiveSmallIntegerField(
|
||||
verbose_name=_('horizontal position'),
|
||||
default=0,
|
||||
help_text=_('Slot index from the left, starting at 0')
|
||||
)
|
||||
|
||||
# Overrides for placeholders without a device type
|
||||
u_height = models.DecimalField(
|
||||
verbose_name=_('height (U)'),
|
||||
max_digits=4,
|
||||
decimal_places=1,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(Decimal('0'))],
|
||||
help_text=_('Overrides the height of the device type')
|
||||
)
|
||||
is_full_depth = models.BooleanField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_('Overrides the depth of the device type')
|
||||
)
|
||||
|
||||
serial = models.CharField(max_length=50, blank=True)
|
||||
label = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
help_text=_('Free text shown in the elevation instead of the name')
|
||||
)
|
||||
|
||||
# Provenance
|
||||
source_device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
description = models.CharField(max_length=200, blank=True)
|
||||
comments = models.TextField(blank=True)
|
||||
|
||||
clone_fields = (
|
||||
'concept', 'status', 'device_type', 'role', 'platform', 'tenant', 'face', 'width_fraction',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('concept', '-position', 'horizontal_position', 'pk')
|
||||
indexes = (
|
||||
models.Index(fields=('concept', 'position'), name='nbrc_device_concept_pos_idx'),
|
||||
)
|
||||
verbose_name = _('concept device')
|
||||
verbose_name_plural = _('concept devices')
|
||||
|
||||
def __str__(self):
|
||||
if self.name:
|
||||
return self.name
|
||||
if self.device_type:
|
||||
return f'{self.device_type} ({self.pk})'
|
||||
return f'Reserved space ({self.pk})'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('plugins:netbox_rack_concept:conceptdevice', args=[self.pk])
|
||||
|
||||
def get_status_color(self):
|
||||
return ConceptDeviceStatusChoices.colors.get(self.status)
|
||||
|
||||
@property
|
||||
def display_label(self):
|
||||
return self.label or self.name or (str(self.device_type) if self.device_type else _('Reserved'))
|
||||
|
||||
@property
|
||||
def effective_u_height(self):
|
||||
if self.u_height is not None:
|
||||
return self.u_height
|
||||
if self.device_type:
|
||||
return Decimal(self.device_type.u_height)
|
||||
return Decimal('1')
|
||||
|
||||
@property
|
||||
def effective_full_depth(self):
|
||||
if self.is_full_depth is not None:
|
||||
return self.is_full_depth
|
||||
if self.device_type:
|
||||
return self.device_type.is_full_depth
|
||||
return True
|
||||
|
||||
@property
|
||||
def lane_span(self):
|
||||
"""(start, end) lane indices on the LANE_COUNT grid."""
|
||||
lanes_per_slot = LANE_COUNT // self.width_fraction
|
||||
start = self.horizontal_position * lanes_per_slot
|
||||
return start, start + lanes_per_slot
|
||||
|
||||
@property
|
||||
def unit_span(self):
|
||||
"""(lowest, highest_exclusive) unit numbers, or None when unracked."""
|
||||
if self.position is None:
|
||||
return None
|
||||
return self.position, self.position + self.effective_u_height
|
||||
|
||||
def occupies_face(self, face):
|
||||
return self.effective_full_depth or self.face == face
|
||||
|
||||
def conflicts_with(self, other):
|
||||
"""True when both devices claim the same physical space."""
|
||||
mine, theirs = self.unit_span, other.unit_span
|
||||
if mine is None or theirs is None:
|
||||
return False
|
||||
if mine[0] >= theirs[1] or theirs[0] >= mine[1]:
|
||||
return False
|
||||
# A full-depth device blocks both faces.
|
||||
if not (self.effective_full_depth or other.effective_full_depth) and self.face != other.face:
|
||||
return False
|
||||
my_lanes, their_lanes = self.lane_span, other.lane_span
|
||||
return my_lanes[0] < their_lanes[1] and their_lanes[0] < my_lanes[1]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.device_type is None and self.u_height is None:
|
||||
raise ValidationError({
|
||||
'u_height': _('Specify a device type or an explicit height.')
|
||||
})
|
||||
|
||||
if self.horizontal_position >= self.width_fraction:
|
||||
raise ValidationError({
|
||||
'horizontal_position': _('With a width of 1/{fraction} the position must be between 0 and {max}.').format(
|
||||
fraction=self.width_fraction, max=self.width_fraction - 1
|
||||
)
|
||||
})
|
||||
|
||||
if self.position is None:
|
||||
return
|
||||
|
||||
if self.position % Decimal('0.5') != 0:
|
||||
raise ValidationError({'position': _('Position must be a multiple of 0.5U.')})
|
||||
|
||||
low, high = self.concept.unit_range
|
||||
span = self.unit_span
|
||||
if span[0] < low or span[1] > high:
|
||||
raise ValidationError({
|
||||
'position': _('A {height}U device at U{position} does not fit in a rack spanning U{low}-U{high}.').format(
|
||||
height=self.effective_u_height, position=self.position, low=low, high=high - 1
|
||||
)
|
||||
})
|
||||
|
||||
siblings = self.concept.devices.exclude(pk=self.pk).filter(position__isnull=False)
|
||||
for other in siblings.select_related('device_type'):
|
||||
if self.conflicts_with(other):
|
||||
raise ValidationError({
|
||||
'position': _('Overlaps with {device} at U{position}.').format(
|
||||
device=other, position=other.position
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
class ConceptDeployment(models.Model):
|
||||
"""Audit trail linking a blueprint to the real racks it produced."""
|
||||
concept = models.ForeignKey(
|
||||
to=RackConcept,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='deployments'
|
||||
)
|
||||
rack = models.ForeignKey(
|
||||
to='dcim.Rack',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
rack_name = models.CharField(max_length=100)
|
||||
device_count = models.PositiveSmallIntegerField(default=0)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
user = models.ForeignKey(
|
||||
to='users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('-created',)
|
||||
verbose_name = _('concept deployment')
|
||||
verbose_name_plural = _('concept deployments')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.concept} → {self.rack_name}'
|
||||
Reference in New Issue
Block a user