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):
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""
|
||||
The three copy paths: rack -> concept, concept -> concept, concept -> rack(s).
|
||||
The four copy paths: rack -> concept, concept -> concept, concept -> rack(s), and
|
||||
concept + concept -> one merged concept.
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@@ -15,6 +17,7 @@ __all__ = (
|
||||
'clone_concept',
|
||||
'copy_rack_to_concept',
|
||||
'deploy_concept',
|
||||
'merge_concepts',
|
||||
'validate_deployment',
|
||||
)
|
||||
|
||||
@@ -34,8 +37,15 @@ def _config():
|
||||
|
||||
@transaction.atomic
|
||||
def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, tenant_group=None,
|
||||
description='', include_devices=True, include_device_names=True):
|
||||
"""Snapshot a real rack, and optionally its contents, into a new blueprint."""
|
||||
description='', include_devices=True, include_device_names=True,
|
||||
override_width=None, override_u_height=None, override_starting_unit=None):
|
||||
"""
|
||||
Snapshot a real rack, and optionally its contents, into a new blueprint. Passing an
|
||||
override lets the concept end up a different size than the source rack (e.g. to
|
||||
plan "what if we had a taller one"); devices that no longer fit are skipped and
|
||||
reported back as warnings rather than aborting the whole copy.
|
||||
Returns (concept, warnings).
|
||||
"""
|
||||
concept = RackConcept(
|
||||
name=name,
|
||||
status=status,
|
||||
@@ -55,16 +65,31 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t
|
||||
value = getattr(rack.rack_type, field, None)
|
||||
if value not in (None, ''):
|
||||
setattr(concept, field, value)
|
||||
|
||||
has_override = any(v is not None for v in (override_width, override_u_height, override_starting_unit))
|
||||
if has_override:
|
||||
# The concept's own geometry fields are ignored on deploy as long as rack_type
|
||||
# is set (NetBox derives geometry from the type). Since the override is the
|
||||
# whole point here, it must not be silently overruled by the source's type.
|
||||
concept.rack_type = None
|
||||
if override_width:
|
||||
concept.width = override_width
|
||||
if override_u_height:
|
||||
concept.u_height = override_u_height
|
||||
if override_starting_unit:
|
||||
concept.starting_unit = override_starting_unit
|
||||
|
||||
concept.full_clean()
|
||||
concept.save()
|
||||
|
||||
warnings = []
|
||||
if include_devices:
|
||||
devices = Device.objects.filter(rack=rack, position__isnull=False).select_related(
|
||||
'device_type', 'role', 'platform', 'tenant'
|
||||
)
|
||||
for device in devices:
|
||||
fraction, horizontal = read_partial_width(device)
|
||||
ConceptDevice.objects.create(
|
||||
concept_device = ConceptDevice(
|
||||
concept=concept,
|
||||
name=(device.name or '') if include_device_names else '',
|
||||
status=ConceptDeviceStatusChoices.STATUS_EXISTING,
|
||||
@@ -79,8 +104,90 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t
|
||||
serial=device.serial if include_device_names else '',
|
||||
source_device=device,
|
||||
)
|
||||
if has_override:
|
||||
try:
|
||||
concept_device.full_clean()
|
||||
except ValidationError:
|
||||
warnings.append(_(
|
||||
'%(device)s (U%(position)s) does not fit in the chosen size and was left out.'
|
||||
) % {'device': device, 'position': device.position})
|
||||
continue
|
||||
concept_device.save()
|
||||
|
||||
return concept
|
||||
return concept, warnings
|
||||
|
||||
|
||||
#
|
||||
# RackConcept + RackConcept -> RackConcept (stacked)
|
||||
#
|
||||
|
||||
@transaction.atomic
|
||||
def merge_concepts(concept_bottom, concept_top, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None,
|
||||
tenant_group=None, width=None, u_height=None, starting_unit=1, description=''):
|
||||
"""
|
||||
Stack two blueprints into one taller one - e.g. two half-height racks combined into
|
||||
a full-height rack. concept_bottom's devices keep their position; concept_top's are
|
||||
shifted up by concept_bottom's height. Neither source concept is modified.
|
||||
Returns (merged_concept, warnings).
|
||||
"""
|
||||
offset = concept_bottom.u_height
|
||||
total_height = u_height or (concept_bottom.u_height + concept_top.u_height)
|
||||
|
||||
merged = RackConcept(
|
||||
name=name,
|
||||
status=status,
|
||||
tenant=tenant,
|
||||
tenant_group=tenant_group,
|
||||
description=description,
|
||||
width=width or concept_bottom.width,
|
||||
u_height=total_height,
|
||||
starting_unit=starting_unit,
|
||||
form_factor=concept_bottom.form_factor,
|
||||
airflow=concept_bottom.airflow,
|
||||
default_site=concept_bottom.default_site or concept_top.default_site,
|
||||
default_location=concept_bottom.default_location or concept_top.default_location,
|
||||
rack_role=concept_bottom.rack_role or concept_top.rack_role,
|
||||
)
|
||||
merged.full_clean()
|
||||
merged.save()
|
||||
|
||||
warnings = []
|
||||
|
||||
def _copy_devices(source, shift):
|
||||
for device in source.devices.all():
|
||||
concept_device = ConceptDevice(
|
||||
concept=merged,
|
||||
name=device.name,
|
||||
label=device.label,
|
||||
status=device.status,
|
||||
device_type=device.device_type,
|
||||
role=device.role,
|
||||
platform=device.platform,
|
||||
tenant=device.tenant,
|
||||
position=(device.position + shift) if device.position is not None else None,
|
||||
face=device.face,
|
||||
width_fraction=device.width_fraction,
|
||||
horizontal_position=device.horizontal_position,
|
||||
u_height=device.u_height,
|
||||
is_full_depth=device.is_full_depth,
|
||||
serial=device.serial,
|
||||
description=device.description,
|
||||
comments=device.comments,
|
||||
source_device=device.source_device,
|
||||
)
|
||||
try:
|
||||
concept_device.full_clean()
|
||||
except ValidationError:
|
||||
warnings.append(_(
|
||||
'%(device)s from %(concept)s does not fit at its shifted position and was left out.'
|
||||
) % {'device': device, 'concept': source})
|
||||
continue
|
||||
concept_device.save()
|
||||
|
||||
_copy_devices(concept_bottom, 0)
|
||||
_copy_devices(concept_top, offset)
|
||||
|
||||
return merged, warnings
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -16,6 +16,9 @@ UNIT_HEIGHT = 22
|
||||
RACK_WIDTH = 240
|
||||
LEGEND_WIDTH = 32
|
||||
MARGIN = 6
|
||||
# x where the rack frame itself starts. Used by the frame, every unit slot and every
|
||||
# device block alike, so none of them can drift out of alignment with the others.
|
||||
RACK_X = MARGIN + LEGEND_WIDTH
|
||||
|
||||
DEFAULT_COLOR = 'c0c0c0'
|
||||
RESERVED_COLOR = 'e9ecef'
|
||||
@@ -44,8 +47,9 @@ class ElevationUnit:
|
||||
class ElevationBlock:
|
||||
"""One rendered device rectangle."""
|
||||
|
||||
def __init__(self, device, x, y, width, height, opposite=False):
|
||||
def __init__(self, device, face, x, y, width, height, opposite=False):
|
||||
self.device = device
|
||||
self.face = face
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.width = width
|
||||
@@ -80,6 +84,19 @@ class ElevationBlock:
|
||||
def show_text(self):
|
||||
return self.height >= 14
|
||||
|
||||
@property
|
||||
def image(self):
|
||||
"""
|
||||
The device type's image for the face this block is drawn on, i.e. what's
|
||||
physically visible from that side of the rack - not the device's own mounting
|
||||
face, which only matters for half-depth devices occupying one face at a time.
|
||||
"""
|
||||
device_type = self.device.device_type
|
||||
if device_type is None:
|
||||
return None
|
||||
image = device_type.front_image if self.face == DeviceFaceChoices.FACE_FRONT else device_type.rear_image
|
||||
return image if image else None
|
||||
|
||||
|
||||
class Elevation:
|
||||
"""Everything the template needs to draw one face of a concept rack."""
|
||||
@@ -92,7 +109,8 @@ class Elevation:
|
||||
|
||||
self.legend_width = LEGEND_WIDTH
|
||||
self.rack_width = RACK_WIDTH
|
||||
self.width = LEGEND_WIDTH + RACK_WIDTH + MARGIN * 2
|
||||
self.rack_x = RACK_X
|
||||
self.width = RACK_X + RACK_WIDTH + MARGIN
|
||||
self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2
|
||||
|
||||
self._build_units()
|
||||
@@ -128,7 +146,7 @@ class Elevation:
|
||||
|
||||
bottom, top = span
|
||||
lane_start, lane_end = device.lane_span
|
||||
x = MARGIN + LEGEND_WIDTH + (lane_start / LANE_COUNT) * RACK_WIDTH
|
||||
x = RACK_X + (lane_start / LANE_COUNT) * RACK_WIDTH
|
||||
width = ((lane_end - lane_start) / LANE_COUNT) * RACK_WIDTH
|
||||
|
||||
if self.concept.desc_units:
|
||||
@@ -138,6 +156,7 @@ class Elevation:
|
||||
|
||||
self.blocks.append(ElevationBlock(
|
||||
device=device,
|
||||
face=self.face,
|
||||
x=x,
|
||||
y=y,
|
||||
width=width,
|
||||
|
||||
@@ -21,6 +21,7 @@ __all__ = (
|
||||
'ConceptDeviceFilterForm',
|
||||
'ConceptDeviceForm',
|
||||
'ConceptDeviceImportForm',
|
||||
'MergeConceptsForm',
|
||||
'RackConceptBulkEditForm',
|
||||
'RackConceptCloneForm',
|
||||
'RackConceptFilterForm',
|
||||
@@ -318,14 +319,108 @@ class RackCopyForm(forms.Form):
|
||||
label=_('Keep device names'),
|
||||
help_text=_('Unchecked, only the device type and position are copied')
|
||||
)
|
||||
override_width = forms.ChoiceField(
|
||||
choices=(('', _('Same as rack')),) + tuple(RackWidthChoices),
|
||||
required=False,
|
||||
label=_('Width')
|
||||
)
|
||||
override_u_height = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
max_value=100,
|
||||
label=_('Height (U)'),
|
||||
help_text=_('Leave empty to keep the rack\'s own height. Devices that no longer fit are left out.')
|
||||
)
|
||||
override_starting_unit = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
label=_('Starting unit'),
|
||||
help_text=_('Leave empty to keep the rack\'s own starting unit')
|
||||
)
|
||||
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')),
|
||||
FieldSet(
|
||||
'override_width', 'override_u_height', 'override_starting_unit',
|
||||
name=_('Concept size (optional)')
|
||||
),
|
||||
)
|
||||
|
||||
def clean_override_width(self):
|
||||
value = self.cleaned_data['override_width']
|
||||
return int(value) if value else None
|
||||
|
||||
|
||||
class MergeConceptsForm(forms.Form):
|
||||
"""Stack two blueprints (e.g. two half-height racks) into one taller concept."""
|
||||
name = forms.CharField(
|
||||
label=_('New 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')
|
||||
)
|
||||
concept_bottom = DynamicModelChoiceField(
|
||||
queryset=RackConcept.objects.all(),
|
||||
label=_('Bottom concept'),
|
||||
help_text=_('Keeps its planned positions unchanged')
|
||||
)
|
||||
concept_top = DynamicModelChoiceField(
|
||||
queryset=RackConcept.objects.all(),
|
||||
label=_('Top concept'),
|
||||
help_text=_("Its devices are shifted up by the bottom concept's height")
|
||||
)
|
||||
width = forms.ChoiceField(
|
||||
choices=RackWidthChoices,
|
||||
initial=RackWidthChoices.WIDTH_19IN,
|
||||
label=_('Width of the combined rack')
|
||||
)
|
||||
u_height = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
max_value=100,
|
||||
label=_('Height (U)'),
|
||||
help_text=_('Leave empty to use the sum of both heights')
|
||||
)
|
||||
starting_unit = forms.IntegerField(
|
||||
initial=1,
|
||||
min_value=1,
|
||||
label=_('Starting unit')
|
||||
)
|
||||
description = forms.CharField(max_length=200, required=False)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('name', 'status', 'description', name=_('Concept')),
|
||||
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
|
||||
FieldSet('concept_bottom', 'concept_top', name=_('Sources')),
|
||||
FieldSet('width', 'u_height', 'starting_unit', name=_('Combined size')),
|
||||
)
|
||||
|
||||
def clean_width(self):
|
||||
return int(self.cleaned_data['width'])
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
bottom, top = cleaned.get('concept_bottom'), cleaned.get('concept_top')
|
||||
if bottom and top and bottom.pk == top.pk:
|
||||
raise forms.ValidationError(_('Choose two different concepts to merge.'))
|
||||
return cleaned
|
||||
|
||||
|
||||
class RackConceptCloneForm(forms.Form):
|
||||
"""Duplicate a blueprint, for building variants."""
|
||||
|
||||
@@ -18,6 +18,13 @@ concept_buttons = [
|
||||
color=ButtonColorChoices.CYAN,
|
||||
permissions=['netbox_rack_concept.add_rackconcept']
|
||||
),
|
||||
PluginMenuButton(
|
||||
link='plugins:netbox_rack_concept:rackconcept_merge',
|
||||
title=_('Merge'),
|
||||
icon_class='mdi mdi-call-merge',
|
||||
color=ButtonColorChoices.PURPLE,
|
||||
permissions=['netbox_rack_concept.add_rackconcept']
|
||||
),
|
||||
]
|
||||
|
||||
device_buttons = [
|
||||
|
||||
@@ -37,11 +37,25 @@
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.concept-elevation .device-image {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.concept-elevation .device-label-bg {
|
||||
fill: #000000;
|
||||
fill-opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.concept-elevation .device-label {
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.concept-elevation .device-label.on-image {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.concept-elevation .opposite-hint {
|
||||
font-size: 9px;
|
||||
opacity: 0.8;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends 'base/layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}{% trans "Merge Rack Concepts" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col col-md-8 offset-md-2">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Merge two concepts into one" %}</h2>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
{% trans "Stacks the bottom concept's devices unchanged and shifts the top concept's devices up by the bottom concept's height, into one new, taller blueprint. Neither source concept is modified." %}
|
||||
</p>
|
||||
{% render_form form %}
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<a href="{{ return_url }}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
|
||||
<button type="submit" class="btn btn-primary">{% trans "Merge" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -15,11 +15,11 @@
|
||||
{# Unit rows and legend #}
|
||||
{% for unit in elevation.units %}
|
||||
<g class="unit">
|
||||
<rect x="{{ elevation.legend_width }}" y="{{ unit.y }}"
|
||||
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
|
||||
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
|
||||
class="unit-slot"
|
||||
data-unit="{{ unit.number }}"></rect>
|
||||
<text x="{{ elevation.legend_width }}" y="{{ unit.y }}" dx="-4" dy="15"
|
||||
<text x="{{ elevation.rack_x }}" y="{{ unit.y }}" dx="-4" dy="15"
|
||||
class="unit-label" text-anchor="end">{{ unit.number }}</text>
|
||||
</g>
|
||||
{% endfor %}
|
||||
@@ -32,13 +32,31 @@
|
||||
data-u-height="{{ block.device.effective_u_height }}"
|
||||
data-width-fraction="{{ block.device.width_fraction }}">
|
||||
<a href="{{ block.device.get_absolute_url }}">
|
||||
{% if block.image %}
|
||||
<clipPath id="concept-block-{{ elevation.face }}-{{ block.device.pk }}">
|
||||
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}" rx="2"></rect>
|
||||
</clipPath>
|
||||
{% endif %}
|
||||
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}"
|
||||
rx="2" fill="#{{ block.color }}"
|
||||
fill-opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
|
||||
class="device-rect"></rect>
|
||||
{% if block.image %}
|
||||
<image href="{{ block.image.url }}" x="{{ block.x }}" y="{{ block.y }}"
|
||||
width="{{ block.width }}" height="{{ block.height }}"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
clip-path="url(#concept-block-{{ elevation.face }}-{{ block.device.pk }})"
|
||||
opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
|
||||
class="device-image"></image>
|
||||
{% endif %}
|
||||
{% if block.show_text %}
|
||||
{% if block.image %}
|
||||
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="16"
|
||||
class="device-label-bg"></rect>
|
||||
{% endif %}
|
||||
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
|
||||
fill="#{{ block.text_color }}" class="device-label">
|
||||
fill="{% if block.image %}#ffffff{% else %}#{{ block.text_color }}{% endif %}"
|
||||
class="device-label {% if block.image %}on-image{% endif %}">
|
||||
{{ block.label }}{% if block.opposite %} <tspan class="opposite-hint">({% trans "rear" %})</tspan>{% endif %}
|
||||
</text>
|
||||
{% endif %}
|
||||
@@ -47,7 +65,7 @@
|
||||
</g>
|
||||
{% endfor %}
|
||||
|
||||
<rect x="{{ elevation.legend_width }}" y="6"
|
||||
<rect x="{{ elevation.rack_x }}" y="6"
|
||||
width="{{ elevation.rack_width }}" height="{{ elevation.height|add:'-12' }}"
|
||||
class="rack-frame" fill="none"></rect>
|
||||
</svg>
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_clone_concept' pk=object.pk %}" class="btn btn-outline-secondary">
|
||||
<i class="mdi mdi-content-duplicate"></i> {% trans "Clone concept" %}
|
||||
</a>
|
||||
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_merge' %}?concept_bottom={{ object.pk }}" class="btn btn-outline-secondary">
|
||||
<i class="mdi mdi-call-merge"></i> {% trans "Merge with another" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_rack %}
|
||||
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_deploy' pk=object.pk %}" class="btn btn-primary">
|
||||
|
||||
@@ -9,6 +9,9 @@ app_name = 'netbox_rack_concept'
|
||||
urlpatterns = [
|
||||
# Rack concepts
|
||||
path('concepts/', include(get_model_urls('netbox_rack_concept', 'rackconcept', detail=False))),
|
||||
# Combine two concepts into a taller one. Listed before the <int:pk>/ include below only
|
||||
# for readability; 'merge' can never match the int converter anyway.
|
||||
path('concepts/merge/', views.MergeConceptsView.as_view(), name='rackconcept_merge'),
|
||||
path('concepts/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'rackconcept'))),
|
||||
|
||||
# Planned devices
|
||||
|
||||
@@ -22,6 +22,7 @@ from .models import ConceptDevice, RackConcept
|
||||
|
||||
__all__ = (
|
||||
'ConceptDeviceView',
|
||||
'MergeConceptsView',
|
||||
'RackConceptView',
|
||||
)
|
||||
|
||||
@@ -201,7 +202,7 @@ class RackCopyView(ObjectPermissionRequiredMixin, View):
|
||||
|
||||
if form.is_valid():
|
||||
try:
|
||||
concept = deploy.copy_rack_to_concept(
|
||||
concept, warnings = deploy.copy_rack_to_concept(
|
||||
rack=rack,
|
||||
name=form.cleaned_data['name'],
|
||||
status=form.cleaned_data['status'],
|
||||
@@ -210,10 +211,15 @@ class RackCopyView(ObjectPermissionRequiredMixin, View):
|
||||
description=form.cleaned_data['description'],
|
||||
include_devices=form.cleaned_data['include_devices'],
|
||||
include_device_names=form.cleaned_data['include_device_names'],
|
||||
override_width=form.cleaned_data['override_width'],
|
||||
override_u_height=form.cleaned_data['override_u_height'],
|
||||
override_starting_unit=form.cleaned_data['override_starting_unit'],
|
||||
)
|
||||
except ValidationError as exc:
|
||||
form.add_error(None, exc)
|
||||
else:
|
||||
for warning in warnings:
|
||||
messages.warning(request, warning)
|
||||
messages.success(request, _('Created concept %(concept)s from rack %(rack)s.') % {
|
||||
'concept': concept, 'rack': rack
|
||||
})
|
||||
@@ -227,6 +233,58 @@ class RackCopyView(ObjectPermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
class MergeConceptsView(ObjectPermissionRequiredMixin, View):
|
||||
"""Stack two blueprints (e.g. two half-height racks) into one taller concept."""
|
||||
queryset = RackConcept.objects.all()
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'netbox_rack_concept.add_rackconcept'
|
||||
|
||||
def get(self, request):
|
||||
form = forms.MergeConceptsForm(initial={
|
||||
'concept_bottom': request.GET.get('concept_bottom'),
|
||||
'concept_top': request.GET.get('concept_top'),
|
||||
})
|
||||
return render(request, 'netbox_rack_concept/concept_merge.html', {
|
||||
'form': form,
|
||||
'return_url': reverse('plugins:netbox_rack_concept:rackconcept_list'),
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
form = forms.MergeConceptsForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
try:
|
||||
merged, warnings = deploy.merge_concepts(
|
||||
concept_bottom=form.cleaned_data['concept_bottom'],
|
||||
concept_top=form.cleaned_data['concept_top'],
|
||||
name=form.cleaned_data['name'],
|
||||
status=form.cleaned_data['status'],
|
||||
tenant=form.cleaned_data['tenant'],
|
||||
tenant_group=form.cleaned_data['tenant_group'],
|
||||
width=form.cleaned_data['width'],
|
||||
u_height=form.cleaned_data['u_height'],
|
||||
starting_unit=form.cleaned_data['starting_unit'],
|
||||
description=form.cleaned_data['description'],
|
||||
)
|
||||
except ValidationError as exc:
|
||||
form.add_error(None, exc)
|
||||
else:
|
||||
for warning in warnings:
|
||||
messages.warning(request, warning)
|
||||
messages.success(request, _('Created %(merged)s by merging %(bottom)s and %(top)s.') % {
|
||||
'merged': merged,
|
||||
'bottom': form.cleaned_data['concept_bottom'],
|
||||
'top': form.cleaned_data['concept_top'],
|
||||
})
|
||||
return redirect(merged.get_absolute_url())
|
||||
|
||||
return render(request, 'netbox_rack_concept/concept_merge.html', {
|
||||
'form': form,
|
||||
'return_url': reverse('plugins:netbox_rack_concept:rackconcept_list'),
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(RackConcept, 'clone_concept', path='clone')
|
||||
class RackConceptCloneView(ObjectPermissionRequiredMixin, View):
|
||||
"""Duplicate a concept to build a variant."""
|
||||
|
||||
Reference in New Issue
Block a user