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:
@@ -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
|
||||
Reference in New Issue
Block a user