Files
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

158 lines
5.7 KiB
Python

"""
Optional interoperability with the two plugins this one is meant to sit next to.
Neither is a hard dependency. Everything here degrades to a no-op when the other
plugin is absent, so the concept plugin works on a bare NetBox 4.6 install.
* netbox_utilities -- partial-width rack devices (several devices per rack unit).
The field names are read from PLUGINS_CONFIG so that a rename on their side is a
configuration change here, not a code change.
* netbox-reorder-rack -- drag & drop ordering of *real* racks. Concepts get their
own drag & drop elevation (see static/, templates/) because that plugin operates on
dcim.Device querysets, which a concept deliberately has none of. Once a concept has
been deployed the resulting rack is an ordinary rack and reorder-rack applies to it.
"""
import logging
from django.apps import apps
from django.conf import settings
logger = logging.getLogger('netbox.plugins.netbox_rack_concept')
__all__ = (
'apply_partial_width',
'get_partial_width_adapter',
'read_partial_width',
'reorder_rack_installed',
)
def _config():
return settings.PLUGINS_CONFIG.get('netbox_rack_concept', {})
def plugin_installed(name):
return name in settings.PLUGINS
def reorder_rack_installed():
return plugin_installed('netbox_reorder_rack')
class PartialWidthAdapter:
"""
Wraps whichever shape netbox_utilities uses to store partial-width data:
either extra fields on dcim.Device, or a side model with a FK to the device.
"""
def __init__(self, model, device_field, fraction_field, position_field):
self.model = model
self.device_field = device_field
self.fraction_field = fraction_field
self.position_field = position_field
def write(self, device, fraction, position):
if self.model is None:
# Fields live directly on the Device.
setattr(device, self.fraction_field, fraction)
setattr(device, self.position_field, position)
device.save(update_fields=[self.fraction_field, self.position_field])
return
self.model.objects.update_or_create(
**{self.device_field: device},
defaults={
self.fraction_field: fraction,
self.position_field: position,
}
)
def read(self, device):
if self.model is None:
return (
getattr(device, self.fraction_field, None),
getattr(device, self.position_field, None),
)
obj = self.model.objects.filter(**{self.device_field: device}).first()
if obj is None:
return None, None
return getattr(obj, self.fraction_field, None), getattr(obj, self.position_field, None)
_ADAPTER_CACHE = {}
def get_partial_width_adapter():
"""Return a PartialWidthAdapter, or None when partial width is unavailable."""
if 'adapter' in _ADAPTER_CACHE:
return _ADAPTER_CACHE['adapter']
adapter = None
config = _config().get('partial_width') or {}
if config.get('enabled') and plugin_installed('netbox_utilities'):
fraction_field = config.get('fraction_field', 'width_fraction')
position_field = config.get('position_field', 'horizontal_position')
model_path = config.get('model')
model = None
if model_path:
try:
model = apps.get_model(model_path)
except (LookupError, ValueError):
logger.warning(
'netbox_rack_concept: partial-width model %r not found; falling back to Device fields. '
'Adjust PLUGINS_CONFIG["netbox_rack_concept"]["partial_width"] to match netbox_utilities.',
model_path
)
if model is None:
device_model = apps.get_model('dcim', 'Device')
field_names = {f.name for f in device_model._meta.get_fields()}
if fraction_field in field_names and position_field in field_names:
adapter = PartialWidthAdapter(None, None, fraction_field, position_field)
else:
logger.warning(
'netbox_rack_concept: netbox_utilities is installed but its partial-width storage could not '
'be located. Partial-width values will not be written when deploying concepts.'
)
else:
adapter = PartialWidthAdapter(
model, config.get('device_field', 'device'), fraction_field, position_field
)
_ADAPTER_CACHE['adapter'] = adapter
return adapter
def apply_partial_width(device, concept_device):
"""
Persist the partial-width placement of a concept device onto the real device that
was created from it. Full-width devices need nothing written.
"""
if concept_device.width_fraction == 1:
return False
adapter = get_partial_width_adapter()
if adapter is None:
return False
try:
adapter.write(device, concept_device.width_fraction, concept_device.horizontal_position)
return True
except Exception as exc: # pragma: no cover - depends on the other plugin's schema
logger.warning('netbox_rack_concept: could not write partial-width data for %s: %s', device, exc)
return False
def read_partial_width(device):
"""Read partial-width placement off a real device when copying a rack into a concept."""
adapter = get_partial_width_adapter()
if adapter is None:
return 1, 0
try:
fraction, position = adapter.read(device)
except Exception as exc: # pragma: no cover
logger.warning('netbox_rack_concept: could not read partial-width data for %s: %s', device, exc)
return 1, 0
return int(fraction or 1), int(position or 0)