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>
158 lines
5.7 KiB
Python
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)
|