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):
"""
+112 -5
View File
@@ -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
#
+22 -3
View File
@@ -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,
+95
View File
@@ -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."""
+7
View File
@@ -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">
+3
View File
@@ -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
+59 -1
View File
@@ -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."""