Fix elevation overlap, add device images, rack merge, and size override
- Fix elevation rendering bug: device blocks were drawn MARGIN px further right than the rack frame/unit slots, causing them to overlap and extend past the right border. All three now share one RACK_X origin. - Show the device type's front/rear image inside each elevation block (clipped to the block, translucent label bar on top for legibility) when one is set on the device type; falls back to a plain colour block with text otherwise. - Add "Merge" action to combine two concepts into one taller one (e.g. two half-height racks stacked into a full-height rack): the top concept's devices are shifted up by the bottom concept's height, neither source is modified. Available from the plugin menu, from a concept's detail page, and via POST /concepts/merge/. - Copying a real rack into a concept can now target a different width, height and/or starting unit than the source rack; devices that no longer fit at the chosen size are skipped and reported as warnings instead of aborting the copy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ from dcim.api.serializers import (
|
||||
DeviceRoleSerializer, DeviceTypeSerializer, LocationSerializer, PlatformSerializer, RackRoleSerializer,
|
||||
RackSerializer, RackTypeSerializer, SiteSerializer,
|
||||
)
|
||||
from dcim.choices import RackWidthChoices
|
||||
from dcim.models import Location, Rack, Site
|
||||
from netbox.api.serializers import NetBoxModelSerializer
|
||||
from tenancy.api.serializers import TenantGroupSerializer, TenantSerializer
|
||||
@@ -93,6 +94,9 @@ class RackCopySerializer(serializers.Serializer):
|
||||
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)
|
||||
override_width = serializers.ChoiceField(choices=RackWidthChoices, required=False, allow_null=True, default=None)
|
||||
override_u_height = serializers.IntegerField(min_value=1, max_value=100, required=False, allow_null=True, default=None)
|
||||
override_starting_unit = serializers.IntegerField(min_value=1, required=False, allow_null=True, default=None)
|
||||
|
||||
|
||||
class RackConceptCloneSerializer(serializers.Serializer):
|
||||
@@ -103,6 +107,24 @@ class RackConceptCloneSerializer(serializers.Serializer):
|
||||
include_devices = serializers.BooleanField(default=True)
|
||||
|
||||
|
||||
class MergeConceptsSerializer(serializers.Serializer):
|
||||
"""POST body for stacking two concepts into one taller one."""
|
||||
concept_bottom = serializers.PrimaryKeyRelatedField(queryset=RackConcept.objects.all())
|
||||
concept_top = serializers.PrimaryKeyRelatedField(queryset=RackConcept.objects.all())
|
||||
name = serializers.CharField(max_length=100)
|
||||
status = serializers.ChoiceField(choices=ConceptStatusChoices, default=ConceptStatusChoices.STATUS_DRAFT)
|
||||
tenant = serializers.PrimaryKeyRelatedField(queryset=Tenant.objects.all(), required=False, allow_null=True)
|
||||
width = serializers.ChoiceField(choices=RackWidthChoices, required=False, allow_null=True, default=None)
|
||||
u_height = serializers.IntegerField(min_value=1, max_value=100, required=False, allow_null=True, default=None)
|
||||
starting_unit = serializers.IntegerField(min_value=1, default=1)
|
||||
description = serializers.CharField(max_length=200, required=False, allow_blank=True, default='')
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs['concept_bottom'].pk == attrs['concept_top'].pk:
|
||||
raise serializers.ValidationError('concept_bottom and concept_top must be different concepts.')
|
||||
return attrs
|
||||
|
||||
|
||||
class ConceptDeploySerializer(serializers.Serializer):
|
||||
"""POST body for materialising a concept."""
|
||||
name_pattern = serializers.CharField(max_length=100)
|
||||
|
||||
@@ -13,8 +13,8 @@ from .. import deploy as deploy_utils
|
||||
from ..elevation import get_elevations
|
||||
from ..models import ConceptDevice, RackConcept
|
||||
from .serializers import (
|
||||
ConceptDeploySerializer, ConceptDeviceSerializer, RackConceptCloneSerializer, RackConceptSerializer,
|
||||
RackCopySerializer,
|
||||
ConceptDeploySerializer, ConceptDeviceSerializer, MergeConceptsSerializer, RackConceptCloneSerializer,
|
||||
RackConceptSerializer, RackCopySerializer,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@@ -41,7 +41,7 @@ class RackConceptViewSet(NetBoxModelViewSet):
|
||||
tenant = data.get('tenant')
|
||||
|
||||
try:
|
||||
concept = deploy_utils.copy_rack_to_concept(
|
||||
concept, warnings = deploy_utils.copy_rack_to_concept(
|
||||
rack=rack,
|
||||
name=data['name'],
|
||||
status=data['status'],
|
||||
@@ -50,15 +50,17 @@ class RackConceptViewSet(NetBoxModelViewSet):
|
||||
description=data['description'],
|
||||
include_devices=data['include_devices'],
|
||||
include_device_names=data['include_device_names'],
|
||||
override_width=data.get('override_width'),
|
||||
override_u_height=data.get('override_u_height'),
|
||||
override_starting_unit=data.get('override_starting_unit'),
|
||||
)
|
||||
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
|
||||
)
|
||||
result = RackConceptSerializer(concept, context={'request': request}).data
|
||||
result['warnings'] = warnings
|
||||
return Response(result, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def clone(self, request, pk=None):
|
||||
@@ -85,6 +87,35 @@ class RackConceptViewSet(NetBoxModelViewSet):
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def merge(self, request):
|
||||
"""Stack two concepts (e.g. two half-height racks) into one taller one."""
|
||||
serializer = MergeConceptsSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
tenant = data.get('tenant')
|
||||
|
||||
try:
|
||||
merged, warnings = deploy_utils.merge_concepts(
|
||||
concept_bottom=data['concept_bottom'],
|
||||
concept_top=data['concept_top'],
|
||||
name=data['name'],
|
||||
status=data['status'],
|
||||
tenant=tenant,
|
||||
tenant_group=tenant.group if tenant else None,
|
||||
width=data.get('width'),
|
||||
u_height=data.get('u_height'),
|
||||
starting_unit=data['starting_unit'],
|
||||
description=data['description'],
|
||||
)
|
||||
except ValidationError as exc:
|
||||
return Response({'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
result = RackConceptSerializer(merged, context={'request': request}).data
|
||||
result['warnings'] = warnings
|
||||
return Response(result, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def deploy(self, request, pk=None):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user