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:
2026-09-16 09:35:18 +02:00
co-authored by Claude Sonnet 5
parent a705b64c22
commit 777ded3900
13 changed files with 432 additions and 22 deletions
+22
View File
@@ -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)
+38 -7
View File
@@ -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):
"""