Files
NetBox-Concept/netbox_rack_concept/elevation.py
T
MrBlakeandClaude Sonnet 5 17cf94ac0d 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>
2026-09-16 09:00:26 +02:00

179 lines
5.5 KiB
Python

"""
Geometry for concept rack elevations.
This does not reuse dcim's RackElevationSVG: that renderer walks dcim.Device
querysets and knows nothing about partial-width slots. Computing the geometry here
keeps the plugin independent of NetBox internals and lets the template render plain
inline SVG that follows the UI theme.
"""
from decimal import Decimal
from dcim.choices import DeviceFaceChoices
from .choices import LANE_COUNT
UNIT_HEIGHT = 22
RACK_WIDTH = 240
LEGEND_WIDTH = 32
MARGIN = 6
DEFAULT_COLOR = 'c0c0c0'
RESERVED_COLOR = 'e9ecef'
def foreground_color(bg_color):
"""Return black or white, whichever reads better on the given hex colour."""
bg_color = bg_color.strip('#')
try:
r, g, b = (int(bg_color[i:i + 2], 16) for i in (0, 2, 4))
except ValueError:
return '000000'
# Perceived luminance (ITU-R BT.601)
return '000000' if (r * 299 + g * 587 + b * 114) / 1000 > 128 else 'ffffff'
class ElevationUnit:
"""One labelled rack unit row."""
def __init__(self, number, y):
self.number = number
self.y = y
self.height = UNIT_HEIGHT
class ElevationBlock:
"""One rendered device rectangle."""
def __init__(self, device, x, y, width, height, opposite=False):
self.device = device
self.x = x
self.y = y
self.width = width
self.height = height
# True when the device is physically mounted on the other face and only
# shows up here because it is full depth.
self.opposite = opposite
@property
def color(self):
if self.device.role and self.device.role.color:
return self.device.role.color
if self.device.device_type is None:
return RESERVED_COLOR
return DEFAULT_COLOR
@property
def text_color(self):
return foreground_color(self.color)
@property
def label(self):
return self.device.display_label
@property
def sublabel(self):
if self.device.device_type:
return str(self.device.device_type)
return ''
@property
def show_text(self):
return self.height >= 14
class Elevation:
"""Everything the template needs to draw one face of a concept rack."""
def __init__(self, concept, face=DeviceFaceChoices.FACE_FRONT, devices=None):
self.concept = concept
self.face = face
self.units = []
self.blocks = []
self.legend_width = LEGEND_WIDTH
self.rack_width = RACK_WIDTH
self.width = LEGEND_WIDTH + RACK_WIDTH + MARGIN * 2
self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2
self._build_units()
self._build_blocks(devices if devices is not None else concept.devices.all())
@property
def face_label(self):
return dict(DeviceFaceChoices).get(self.face, self.face)
def _y_for(self, top_unit):
"""Pixel offset of the top edge of a block whose highest unit boundary is `top_unit`."""
concept = self.concept
if concept.desc_units:
# U1 at the top: the block's top edge is its lowest unit number.
bottom = top_unit
offset = Decimal(bottom) - Decimal(concept.starting_unit)
else:
offset = Decimal(concept.starting_unit + concept.u_height) - Decimal(top_unit)
return float(offset) * UNIT_HEIGHT + MARGIN
def _build_units(self):
for number in self.concept.units:
top = number if self.concept.desc_units else number + 1
self.units.append(ElevationUnit(number, self._y_for(Decimal(top))))
def _build_blocks(self, devices):
for device in devices:
span = device.unit_span
if span is None:
continue
if not device.occupies_face(self.face):
continue
bottom, top = span
lane_start, lane_end = device.lane_span
x = MARGIN + LEGEND_WIDTH + (lane_start / LANE_COUNT) * RACK_WIDTH
width = ((lane_end - lane_start) / LANE_COUNT) * RACK_WIDTH
if self.concept.desc_units:
y = self._y_for(bottom)
else:
y = self._y_for(top)
self.blocks.append(ElevationBlock(
device=device,
x=x,
y=y,
width=width,
height=float(top - bottom) * UNIT_HEIGHT,
opposite=device.face != self.face,
))
def get_elevations(concept):
"""Front and rear elevation, sharing one prefetched device list."""
devices = list(concept.devices.select_related('device_type', 'role', 'device_type__manufacturer'))
return (
Elevation(concept, DeviceFaceChoices.FACE_FRONT, devices),
Elevation(concept, DeviceFaceChoices.FACE_REAR, devices),
)
def available_units(concept, u_height=Decimal('1'), face=DeviceFaceChoices.FACE_FRONT, exclude=None):
"""
Unit numbers where a device of the given height would fit on the given face at
full width. Used by the position dropdown in the device form.
"""
devices = [
d for d in concept.devices.select_related('device_type')
if d.position is not None and d.pk != exclude and d.occupies_face(face)
]
low, high = concept.unit_range
result = []
unit = low
while unit + u_height <= high:
blocked = any(
not (unit + u_height <= d.unit_span[0] or d.unit_span[1] <= unit)
for d in devices
)
if not blocked:
result.append(unit)
unit += 1
return result