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
@@ -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">