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:
2026-09-16 09:00:26 +02:00
co-authored by Claude Sonnet 5
commit 17cf94ac0d
34 changed files with 3821 additions and 0 deletions
+458
View File
@@ -0,0 +1,458 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from dcim.choices import DeviceFaceChoices, RackAirflowChoices, RackFormFactorChoices, RackWidthChoices
from dcim.models import DeviceRole, DeviceType, Location, Platform, Rack, RackRole, RackType, Site
from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms.fields import (
CommentField, CSVChoiceField, CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
TagFilterField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices
from .models import ConceptDevice, RackConcept
__all__ = (
'ConceptDeployForm',
'ConceptDeviceBulkEditForm',
'ConceptDeviceFilterForm',
'ConceptDeviceForm',
'ConceptDeviceImportForm',
'RackConceptBulkEditForm',
'RackConceptCloneForm',
'RackConceptFilterForm',
'RackConceptForm',
'RackConceptImportForm',
'RackCopyForm',
)
class RackConceptForm(NetBoxModelForm):
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
initial_params={'tenants': '$tenant'},
label=_('Tenant group')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={'group_id': '$tenant_group'},
label=_('Tenant')
)
rack_type = DynamicModelChoiceField(
queryset=RackType.objects.all(),
required=False,
label=_('Rack type')
)
rack_role = DynamicModelChoiceField(
queryset=RackRole.objects.all(),
required=False,
label=_('Rack role')
)
default_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
label=_('Default site')
)
default_location = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={'site_id': '$default_site'},
label=_('Default location')
)
comments = CommentField()
fieldsets = (
FieldSet('name', 'status', 'description', 'tags', name=_('Concept')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet(
'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'airflow',
name=_('Geometry')
),
FieldSet(
'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'max_weight',
name=_('Dimensions')
),
FieldSet('default_site', 'default_location', 'rack_role', name=_('Deployment defaults')),
)
class Meta:
model = RackConcept
fields = (
'name', 'status', 'description', 'tenant_group', 'tenant', 'rack_type', 'form_factor', 'width',
'u_height', 'starting_unit', 'desc_units', 'airflow', 'outer_width', 'outer_height', 'outer_depth',
'outer_unit', 'mounting_depth', 'max_weight', 'default_site', 'default_location', 'rack_role',
'comments', 'tags',
)
class RackConceptFilterForm(NetBoxModelFilterSetForm):
model = RackConcept
status = forms.MultipleChoiceField(
choices=ConceptStatusChoices,
required=False
)
tenant_group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_('Tenant group')
)
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
width = forms.MultipleChoiceField(
choices=RackWidthChoices,
required=False
)
form_factor = forms.MultipleChoiceField(
choices=RackFormFactorChoices,
required=False
)
tag = TagFilterField(model)
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('status', 'width', 'form_factor', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenancy')),
)
class RackConceptBulkEditForm(NetBoxModelBulkEditForm):
model = RackConcept
status = forms.ChoiceField(choices=ConceptStatusChoices, required=False)
tenant_group = DynamicModelChoiceField(queryset=TenantGroup.objects.all(), required=False)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={'group_id': '$tenant_group'}
)
description = forms.CharField(max_length=200, required=False)
comments = CommentField()
nullable_fields = ('tenant_group', 'tenant', 'description', 'comments')
fieldsets = (
FieldSet('status', 'description', name=_('Concept')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
class RackConceptImportForm(NetBoxModelImportForm):
status = CSVChoiceField(choices=ConceptStatusChoices, required=False)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
to_field_name='name',
required=False
)
class Meta:
model = RackConcept
fields = (
'name', 'status', 'description', 'tenant', 'form_factor', 'width', 'u_height', 'starting_unit',
'desc_units', 'comments', 'tags',
)
class ConceptDeviceForm(NetBoxModelForm):
concept = DynamicModelChoiceField(
queryset=RackConcept.objects.all(),
label=_('Rack concept')
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device type')
)
role = DynamicModelChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Role')
)
platform = DynamicModelChoiceField(
queryset=Platform.objects.all(),
required=False,
label=_('Platform')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
comments = CommentField()
fieldsets = (
FieldSet('concept', 'name', 'label', 'status', 'description', 'tags', name=_('Planned device')),
FieldSet('device_type', 'role', 'platform', 'serial', name=_('Hardware')),
FieldSet('position', 'face', 'width_fraction', 'horizontal_position', name=_('Placement')),
FieldSet('u_height', 'is_full_depth', name=_('Overrides')),
FieldSet('tenant', name=_('Tenancy')),
)
class Meta:
model = ConceptDevice
fields = (
'concept', 'name', 'label', 'status', 'description', 'device_type', 'role', 'platform', 'serial',
'position', 'face', 'width_fraction', 'horizontal_position', 'u_height', 'is_full_depth', 'tenant',
'comments', 'tags',
)
class ConceptDeviceFilterForm(NetBoxModelFilterSetForm):
model = ConceptDevice
concept_id = DynamicModelMultipleChoiceField(
queryset=RackConcept.objects.all(),
required=False,
label=_('Rack concept')
)
status = forms.MultipleChoiceField(choices=ConceptDeviceStatusChoices, required=False)
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device type')
)
role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Role')
)
face = forms.MultipleChoiceField(choices=DeviceFaceChoices, required=False)
tag = TagFilterField(model)
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('concept_id', 'status', 'face', name=_('Placement')),
FieldSet('device_type_id', 'role_id', name=_('Hardware')),
)
class ConceptDeviceBulkEditForm(NetBoxModelBulkEditForm):
model = ConceptDevice
status = forms.ChoiceField(choices=ConceptDeviceStatusChoices, required=False)
role = DynamicModelChoiceField(queryset=DeviceRole.objects.all(), required=False)
platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False)
tenant = DynamicModelChoiceField(queryset=Tenant.objects.all(), required=False)
face = forms.ChoiceField(choices=DeviceFaceChoices, required=False)
width_fraction = forms.ChoiceField(choices=WidthFractionChoices, required=False)
is_full_depth = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect())
description = forms.CharField(max_length=200, required=False)
nullable_fields = ('role', 'platform', 'tenant', 'description', 'is_full_depth')
fieldsets = (
FieldSet('status', 'role', 'platform', 'description', name=_('Planned device')),
FieldSet('face', 'width_fraction', 'is_full_depth', name=_('Placement')),
FieldSet('tenant', name=_('Tenancy')),
)
class ConceptDeviceImportForm(NetBoxModelImportForm):
concept = CSVModelChoiceField(
queryset=RackConcept.objects.all(),
to_field_name='name'
)
device_type = CSVModelChoiceField(
queryset=DeviceType.objects.all(),
to_field_name='model',
required=False
)
role = CSVModelChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='name',
required=False
)
status = CSVChoiceField(choices=ConceptDeviceStatusChoices, required=False)
face = CSVChoiceField(choices=DeviceFaceChoices, required=False)
class Meta:
model = ConceptDevice
fields = (
'concept', 'name', 'label', 'status', 'device_type', 'role', 'position', 'face', 'width_fraction',
'horizontal_position', 'u_height', 'is_full_depth', 'description', 'tags',
)
#
# Action forms
#
class RackCopyForm(forms.Form):
"""Copy an existing dcim.Rack into a new blueprint."""
name = forms.CharField(
label=_('Concept name'),
max_length=100
)
status = forms.ChoiceField(
choices=ConceptStatusChoices,
initial=ConceptStatusChoices.STATUS_DRAFT
)
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_('Tenant group')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={'group_id': '$tenant_group'},
label=_('Tenant')
)
include_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Copy installed devices'),
help_text=_('Snapshot every device currently mounted in the rack')
)
include_device_names = forms.BooleanField(
required=False,
initial=True,
label=_('Keep device names'),
help_text=_('Unchecked, only the device type and position are copied')
)
description = forms.CharField(max_length=200, required=False)
fieldsets = (
FieldSet('name', 'status', 'description', name=_('Concept')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet('include_devices', 'include_device_names', name=_('Content')),
)
class RackConceptCloneForm(forms.Form):
"""Duplicate a blueprint, for building variants."""
name = forms.CharField(
label=_('New concept name'),
max_length=100
)
status = forms.ChoiceField(
choices=ConceptStatusChoices,
initial=ConceptStatusChoices.STATUS_DRAFT
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
include_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Copy planned devices')
)
fieldsets = (
FieldSet('name', 'status', 'tenant', 'include_devices'),
)
class ConceptDeployForm(forms.Form):
"""Materialise a blueprint as one or more real racks."""
name_pattern = forms.CharField(
label=_('Rack name'),
max_length=100,
help_text=_('Use {n} for the sequence number, e.g. "DC1-R{n}". Zero-pad with {n:02d}.')
)
count = forms.IntegerField(
label=_('Number of racks'),
min_value=1,
max_value=100,
initial=1
)
start_index = forms.IntegerField(
label=_('First sequence number'),
initial=1
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label=_('Site')
)
location = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={'site_id': '$site'},
label=_('Location')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
create_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Create devices'),
help_text=_('Create a real device for every planned device that has a device type and a position')
)
device_name_pattern = forms.CharField(
max_length=100,
required=False,
label=_('Device name'),
help_text=_('Optional. {rack} and {name} are replaced, e.g. "{rack}-{name}". '
'Empty keeps the planned name.')
)
skip_unnamed_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Skip devices without a name'),
help_text=_('Unchecked, unnamed planned devices are created as unnamed devices')
)
fieldsets = (
FieldSet('name_pattern', 'count', 'start_index', name=_('Racks')),
FieldSet('site', 'location', 'tenant', name=_('Placement')),
FieldSet('create_devices', 'device_name_pattern', 'skip_unnamed_devices', name=_('Devices')),
)
def __init__(self, *args, concept=None, **kwargs):
super().__init__(*args, **kwargs)
self.concept = concept
if concept and not self.is_bound:
self.fields['name_pattern'].initial = concept.name
if concept.default_site:
self.fields['site'].initial = concept.default_site.pk
if concept.default_location:
self.fields['location'].initial = concept.default_location.pk
if concept.tenant:
self.fields['tenant'].initial = concept.tenant.pk
def clean_name_pattern(self):
pattern = self.cleaned_data['name_pattern']
try:
pattern.format(n=1)
except (IndexError, KeyError, ValueError) as exc:
raise forms.ValidationError(
_('Invalid name pattern: {error}').format(error=exc)
)
return pattern
def clean(self):
cleaned = super().clean()
pattern = cleaned.get('name_pattern')
count = cleaned.get('count')
start = cleaned.get('start_index')
site = cleaned.get('site')
if not (pattern and count and start is not None and site):
return cleaned
names = [pattern.format(n=i) for i in range(start, start + count)]
if len(set(names)) != len(names):
raise forms.ValidationError({
'name_pattern': _('The pattern produces duplicate names. Include {n} when creating more than one rack.')
})
clashes = Rack.objects.filter(site=site, name__in=names).values_list('name', flat=True)
if clashes:
raise forms.ValidationError({
'name_pattern': _('These racks already exist at {site}: {names}').format(
site=site, names=', '.join(sorted(clashes))
)
})
cleaned['rack_names'] = names
return cleaned