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,161 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.api.serializers import (
|
||||
DeviceRoleSerializer, DeviceTypeSerializer, LocationSerializer, PlatformSerializer, RackRoleSerializer,
|
||||
RackSerializer, RackTypeSerializer, SiteSerializer,
|
||||
)
|
||||
from netbox.api.serializers import NetBoxModelSerializer
|
||||
from tenancy.api.serializers import TenantGroupSerializer, TenantSerializer
|
||||
|
||||
from ..choices import ConceptStatusChoices
|
||||
from ..models import ConceptDeployment, ConceptDevice, RackConcept
|
||||
|
||||
__all__ = (
|
||||
'ConceptDeploySerializer',
|
||||
'ConceptDeploymentSerializer',
|
||||
'ConceptDeviceSerializer',
|
||||
'RackConceptCloneSerializer',
|
||||
'RackConceptSerializer',
|
||||
'RackCopySerializer',
|
||||
)
|
||||
|
||||
|
||||
class RackConceptSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name='plugins-api:netbox_rack_concept-api:rackconcept-detail'
|
||||
)
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||
tenant_group = TenantGroupSerializer(nested=True, required=False, allow_null=True)
|
||||
rack_type = RackTypeSerializer(nested=True, required=False, allow_null=True)
|
||||
rack_role = RackRoleSerializer(nested=True, required=False, allow_null=True)
|
||||
default_site = SiteSerializer(nested=True, required=False, allow_null=True)
|
||||
default_location = LocationSerializer(nested=True, required=False, allow_null=True)
|
||||
source_rack = RackSerializer(nested=True, required=False, allow_null=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
utilization = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RackConcept
|
||||
fields = (
|
||||
'id', 'url', 'display_url', 'display', 'name', 'status', 'description', 'tenant_group', 'tenant',
|
||||
'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
|
||||
'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'max_weight',
|
||||
'default_site', 'default_location', 'rack_role', 'source_rack', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'device_count', 'utilization',
|
||||
)
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'status', 'u_height', 'description')
|
||||
|
||||
|
||||
class ConceptDeviceSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name='plugins-api:netbox_rack_concept-api:conceptdevice-detail'
|
||||
)
|
||||
concept = RackConceptSerializer(nested=True)
|
||||
device_type = DeviceTypeSerializer(nested=True, required=False, allow_null=True)
|
||||
role = DeviceRoleSerializer(nested=True, required=False, allow_null=True)
|
||||
platform = PlatformSerializer(nested=True, required=False, allow_null=True)
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||
effective_u_height = serializers.DecimalField(
|
||||
max_digits=4, decimal_places=1, read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConceptDevice
|
||||
fields = (
|
||||
'id', 'url', 'display_url', 'display', 'concept', 'name', 'label', 'status', 'device_type', 'role',
|
||||
'platform', 'tenant', 'position', 'face', 'width_fraction', 'horizontal_position', 'u_height',
|
||||
'is_full_depth', 'effective_u_height', 'serial', 'source_device', 'description', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
)
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'position', 'face', 'device_type')
|
||||
|
||||
|
||||
class ConceptDeploymentSerializer(serializers.ModelSerializer):
|
||||
rack = RackSerializer(nested=True, read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = ConceptDeployment
|
||||
fields = ('id', 'rack', 'rack_name', 'device_count', 'user', 'created')
|
||||
|
||||
|
||||
#
|
||||
# Action payloads
|
||||
#
|
||||
|
||||
class RackCopySerializer(serializers.Serializer):
|
||||
"""POST body for copying a real rack into a new concept."""
|
||||
rack = serializers.PrimaryKeyRelatedField(queryset=None)
|
||||
name = serializers.CharField(max_length=100)
|
||||
status = serializers.ChoiceField(choices=ConceptStatusChoices, default=ConceptStatusChoices.STATUS_DRAFT)
|
||||
tenant = serializers.PrimaryKeyRelatedField(queryset=None, required=False, allow_null=True)
|
||||
description = serializers.CharField(max_length=200, required=False, allow_blank=True, default='')
|
||||
include_devices = serializers.BooleanField(default=True)
|
||||
include_device_names = serializers.BooleanField(default=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
from dcim.models import Rack
|
||||
from tenancy.models import Tenant
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['rack'].queryset = Rack.objects.all()
|
||||
self.fields['tenant'].queryset = Tenant.objects.all()
|
||||
|
||||
|
||||
class RackConceptCloneSerializer(serializers.Serializer):
|
||||
"""POST body for duplicating a concept."""
|
||||
name = serializers.CharField(max_length=100)
|
||||
status = serializers.ChoiceField(choices=ConceptStatusChoices, default=ConceptStatusChoices.STATUS_DRAFT)
|
||||
tenant = serializers.PrimaryKeyRelatedField(queryset=None, required=False, allow_null=True)
|
||||
include_devices = serializers.BooleanField(default=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
from tenancy.models import Tenant
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['tenant'].queryset = Tenant.objects.all()
|
||||
|
||||
|
||||
class ConceptDeploySerializer(serializers.Serializer):
|
||||
"""POST body for materialising a concept."""
|
||||
name_pattern = serializers.CharField(max_length=100)
|
||||
count = serializers.IntegerField(min_value=1, max_value=100, default=1)
|
||||
start_index = serializers.IntegerField(default=1)
|
||||
site = serializers.PrimaryKeyRelatedField(queryset=None)
|
||||
location = serializers.PrimaryKeyRelatedField(queryset=None, required=False, allow_null=True)
|
||||
tenant = serializers.PrimaryKeyRelatedField(queryset=None, required=False, allow_null=True)
|
||||
create_devices = serializers.BooleanField(default=True)
|
||||
device_name_pattern = serializers.CharField(max_length=100, required=False, allow_blank=True, default='')
|
||||
skip_unnamed_devices = serializers.BooleanField(default=True)
|
||||
dry_run = serializers.BooleanField(
|
||||
default=False,
|
||||
help_text='Return the planned rack names and warnings without creating anything'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
from dcim.models import Location, Site
|
||||
from tenancy.models import Tenant
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['site'].queryset = Site.objects.all()
|
||||
self.fields['location'].queryset = Location.objects.all()
|
||||
self.fields['tenant'].queryset = Tenant.objects.all()
|
||||
|
||||
def validate(self, attrs):
|
||||
from dcim.models import Rack
|
||||
try:
|
||||
names = [
|
||||
attrs['name_pattern'].format(n=i)
|
||||
for i in range(attrs['start_index'], attrs['start_index'] + attrs['count'])
|
||||
]
|
||||
except (IndexError, KeyError, ValueError) as exc:
|
||||
raise serializers.ValidationError({'name_pattern': f'Invalid pattern: {exc}'})
|
||||
|
||||
if len(set(names)) != len(names):
|
||||
raise serializers.ValidationError({
|
||||
'name_pattern': 'The pattern produces duplicate names. Include {n} when creating more than one rack.'
|
||||
})
|
||||
clashes = list(Rack.objects.filter(site=attrs['site'], name__in=names).values_list('name', flat=True))
|
||||
if clashes:
|
||||
raise serializers.ValidationError({
|
||||
'name_pattern': f'These racks already exist at the site: {", ".join(sorted(clashes))}'
|
||||
})
|
||||
|
||||
attrs['rack_names'] = names
|
||||
return attrs
|
||||
@@ -0,0 +1,11 @@
|
||||
from netbox.api.routers import NetBoxRouter
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'netbox_rack_concept-api'
|
||||
|
||||
router = NetBoxRouter()
|
||||
router.register('concepts', views.RackConceptViewSet)
|
||||
router.register('devices', views.ConceptDeviceViewSet)
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,175 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from dcim.models import Rack
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
|
||||
from .. import filtersets
|
||||
from .. import deploy as deploy_utils
|
||||
from ..elevation import get_elevations
|
||||
from ..models import ConceptDevice, RackConcept
|
||||
from .serializers import (
|
||||
ConceptDeploySerializer, ConceptDeviceSerializer, RackConceptCloneSerializer, RackConceptSerializer,
|
||||
RackCopySerializer,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
'ConceptDeviceViewSet',
|
||||
'RackConceptViewSet',
|
||||
)
|
||||
|
||||
|
||||
class RackConceptViewSet(NetBoxModelViewSet):
|
||||
queryset = RackConcept.objects.prefetch_related('tags').annotate(
|
||||
device_count=Count('devices')
|
||||
).select_related('tenant', 'tenant_group', 'rack_type')
|
||||
serializer_class = RackConceptSerializer
|
||||
filterset_class = filtersets.RackConceptFilterSet
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='copy-from-rack')
|
||||
def copy_from_rack(self, request):
|
||||
"""Create a concept from an existing rack, optionally including its devices."""
|
||||
serializer = RackCopySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
rack = get_object_or_404(Rack.objects.restrict(request.user, 'view'), pk=data['rack'].pk)
|
||||
tenant = data.get('tenant')
|
||||
|
||||
try:
|
||||
concept = deploy_utils.copy_rack_to_concept(
|
||||
rack=rack,
|
||||
name=data['name'],
|
||||
status=data['status'],
|
||||
tenant=tenant,
|
||||
tenant_group=tenant.group if tenant else None,
|
||||
description=data['description'],
|
||||
include_devices=data['include_devices'],
|
||||
include_device_names=data['include_device_names'],
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return Response({'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response(
|
||||
RackConceptSerializer(concept, context={'request': request}).data,
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def clone(self, request, pk=None):
|
||||
"""Duplicate this concept into a new one."""
|
||||
concept = self.get_object()
|
||||
serializer = RackConceptCloneSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
try:
|
||||
new_concept = deploy_utils.clone_concept(
|
||||
concept=concept,
|
||||
name=data['name'],
|
||||
status=data['status'],
|
||||
tenant=data.get('tenant'),
|
||||
include_devices=data['include_devices'],
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return Response({'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response(
|
||||
RackConceptSerializer(new_concept, context={'request': request}).data,
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def deploy(self, request, pk=None):
|
||||
"""
|
||||
Materialise this concept as one or more real racks. Pass dry_run=true to get the
|
||||
rack names and warnings back without writing anything.
|
||||
"""
|
||||
concept = self.get_object()
|
||||
serializer = ConceptDeploySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
if not request.user.has_perm('dcim.add_rack'):
|
||||
return Response({'detail': 'Permission denied: dcim.add_rack'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
planned, warnings = deploy_utils.validate_deployment(
|
||||
concept, data['create_devices'], data['skip_unnamed_devices']
|
||||
)
|
||||
|
||||
if data['dry_run']:
|
||||
return Response({
|
||||
'dry_run': True,
|
||||
'racks': data['rack_names'],
|
||||
'devices_per_rack': len(planned),
|
||||
'total_devices': len(planned) * len(data['rack_names']),
|
||||
'warnings': warnings,
|
||||
})
|
||||
|
||||
try:
|
||||
racks, devices, warnings = deploy_utils.deploy_concept(
|
||||
concept=concept,
|
||||
rack_names=data['rack_names'],
|
||||
site=data['site'],
|
||||
location=data.get('location'),
|
||||
tenant=data.get('tenant'),
|
||||
create_devices=data['create_devices'],
|
||||
device_name_pattern=data['device_name_pattern'],
|
||||
skip_unnamed_devices=data['skip_unnamed_devices'],
|
||||
user=request.user,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return Response(
|
||||
{
|
||||
'detail': 'Deployment failed; nothing was created.',
|
||||
'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return Response({
|
||||
'racks': [{'id': r.pk, 'name': r.name, 'url': r.get_absolute_url()} for r in racks],
|
||||
'device_count': len(devices),
|
||||
'warnings': warnings,
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def elevation(self, request, pk=None):
|
||||
"""The computed elevation geometry, for external renderers."""
|
||||
concept = self.get_object()
|
||||
front, rear = get_elevations(concept)
|
||||
return Response({
|
||||
'u_height': concept.u_height,
|
||||
'starting_unit': concept.starting_unit,
|
||||
'desc_units': concept.desc_units,
|
||||
'faces': {
|
||||
elevation.face: [
|
||||
{
|
||||
'device_id': block.device.pk,
|
||||
'label': block.label,
|
||||
'position': block.device.position,
|
||||
'u_height': block.device.effective_u_height,
|
||||
'width_fraction': block.device.width_fraction,
|
||||
'horizontal_position': block.device.horizontal_position,
|
||||
'mounted_on_opposite_face': block.opposite,
|
||||
'color': block.color,
|
||||
}
|
||||
for block in elevation.blocks
|
||||
]
|
||||
for elevation in (front, rear)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
class ConceptDeviceViewSet(NetBoxModelViewSet):
|
||||
queryset = ConceptDevice.objects.prefetch_related('tags').select_related(
|
||||
'concept', 'device_type', 'device_type__manufacturer', 'role', 'platform', 'tenant'
|
||||
)
|
||||
serializer_class = ConceptDeviceSerializer
|
||||
filterset_class = filtersets.ConceptDeviceFilterSet
|
||||
Reference in New Issue
Block a user