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>
462 lines
15 KiB
Python
462 lines
15 KiB
Python
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}'
|