Add images/labels toggle, clean up label styling, merge real racks

- Replace the device-image label background bar and text-shadow with
  NetBox's own technique: a black-stroke pass behind a white-fill pass
  of the same text, giving crisp readable labels over any image
  without a dark box behind them.
- Add an "Images and labels / Images only / Labels only" selector
  above the elevation, mirroring the equivalent control on real NetBox
  rack elevations. The plain label is always in the markup; the
  selector only toggles the image and its over-image label, exactly
  like core NetBox does it. Choice persists per browser via
  localStorage and works without edit permissions.
- Add a "Merge into concept" button to the real dcim Rack list: select
  two racks via the existing bulk-select checkboxes and it forwards
  them to the concept-merge page, pre-filled. New
  deploy.merge_racks_to_concept() combines two real racks' devices
  directly into one new, stacked blueprint without an intermediate
  per-rack copy step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 09:50:23 +02:00
co-authored by Claude Sonnet 5
parent 777ded3900
commit e4817ea83b
13 changed files with 430 additions and 39 deletions
+5 -2
View File
@@ -15,6 +15,7 @@ Wenn die Planung steht, wird das Konzept per Knopfdruck in echte NetBox-Objekte
| Echtes Rack → Konzept | Button **„Copy to concept"** auf der Rack-Detailseite. Größe des Konzept-Racks (Breite, Höhe, Startunit) ist dabei frei wählbar; Geräte, die in der gewählten Größe keinen Platz mehr finden, werden ausgelassen und als Warnung gemeldet |
| Konzept → Konzept (Variante) | Button **„Clone concept"** auf der Konzept-Detailseite |
| 2 Konzepte → 1 größeres Konzept | Button **„Merge"** im Menü bzw. **„Merge with another"** auf der Konzept-Detailseite — stapelt z.B. zwei halbhohe Racks zu einem vollen; das obere Konzept wird um die Höhe des unteren nach oben verschoben, beide Quellen bleiben unverändert |
| 2 echte Racks → 1 neues Konzept | Button **„Merge into concept"** über der echten Rack-Liste. Zwei Racks per Checkbox auswählen (wie bei jeder anderen Bulk-Aktion) und auf den Button klicken — vorausgefüllt landet man auf derselben Merge-Maske wie oben, diesmal direkt mit den zwei echten Racks als Quelle |
| Konzept → echte Racks (n-fach) | Button **„Deploy to NetBox"**, mit Namensschema `DC1-R{n:02d}` |
| Konzept von Hand anlegen | Menü **Rack Concepts → Rack Concepts → +** |
@@ -23,8 +24,10 @@ Dazu:
* **Elevation mit Drag & Drop** – Geräte per Maus verschieben, serverseitig validiert.
Ein ungültiger Zug wird abgelehnt und ändert nichts.
* **Geräte mit Bild und Text** – ist am Gerätetyp ein Front-/Rear-Bild hinterlegt, wird
es im Elevation-Block angezeigt, mit halbtransparentem Label darüber; ohne Bild bleibt
es bei der reinen Farbfläche mit Text.
es im Elevation-Block angezeigt; darüber liegt der Gerätename als weiße, schwarz
konturierte Schrift (kein dunkler Balken). Ein Dropdown über der Elevation schaltet
zwischen „Images and labels", „Images only" und „Labels only" um — genau wie bei
echten Racks in NetBox, inklusive Merken der Wahl im Browser.
* **Teilbreiten** (1/1, 1/2, 1/3, 1/4 plus horizontale Slot-Position), passend zum
Teilbreiten-Feature von `netbox_utilities`.
* **Mandant und Mandantengruppe** an Konzept und an jedem geplanten Gerät.
+90 -6
View File
@@ -1,12 +1,13 @@
"""
The four copy paths: rack -> concept, concept -> concept, concept -> rack(s), and
concept + concept -> one merged concept.
The five copy paths: rack -> concept, concept -> concept, concept -> rack(s),
concept + concept -> one merged concept, and rack + rack -> 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 _
from dcim.choices import RackWidthChoices
from dcim.models import Device, Rack
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices
@@ -18,6 +19,7 @@ __all__ = (
'copy_rack_to_concept',
'deploy_concept',
'merge_concepts',
'merge_racks_to_concept',
'validate_deployment',
)
@@ -31,6 +33,14 @@ def _config():
return settings.PLUGINS_CONFIG.get('netbox_rack_concept', {})
def _rack_geometry(rack, field):
"""A rack's own value for `field`, falling back to its rack type when left empty."""
value = getattr(rack, field, None)
if value in (None, '') and rack.rack_type:
value = getattr(rack.rack_type, field, None)
return value
#
# dcim.Rack -> RackConcept
#
@@ -59,10 +69,7 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t
source_rack=rack,
)
for field in GEOMETRY_FIELDS:
value = getattr(rack, field, None)
# A rack built from a rack type may leave its own dimension fields empty.
if value in (None, '') and rack.rack_type:
value = getattr(rack.rack_type, field, None)
value = _rack_geometry(rack, field)
if value not in (None, ''):
setattr(concept, field, value)
@@ -190,6 +197,83 @@ def merge_concepts(concept_bottom, concept_top, name, status=ConceptStatusChoice
return merged, warnings
#
# dcim.Rack + dcim.Rack -> RackConcept (stacked)
#
@transaction.atomic
def merge_racks_to_concept(rack_bottom, rack_top, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None,
tenant_group=None, width=None, u_height=None, starting_unit=1, description='',
include_devices=True, include_device_names=True):
"""
Combine two real racks directly into one new, taller blueprint - e.g. two
half-height racks planned as one full-height rack. rack_bottom's devices keep their
position; rack_top's are shifted up by rack_bottom's height. Neither source rack is
modified. Returns (concept, warnings).
"""
bottom_height = _rack_geometry(rack_bottom, 'u_height') or rack_bottom.u_height
top_height = _rack_geometry(rack_top, 'u_height') or rack_top.u_height
total_height = u_height or (bottom_height + top_height)
concept = RackConcept(
name=name,
status=status,
tenant=tenant,
tenant_group=tenant_group,
description=description,
width=width or _rack_geometry(rack_bottom, 'width') or RackWidthChoices.WIDTH_19IN,
u_height=total_height,
starting_unit=starting_unit,
form_factor=_rack_geometry(rack_bottom, 'form_factor') or '',
airflow=rack_bottom.airflow,
default_site=rack_bottom.site,
default_location=rack_bottom.location,
rack_role=rack_bottom.role,
source_rack=rack_bottom,
)
concept.full_clean()
concept.save()
warnings = []
def _copy_from_rack(rack, shift):
if not include_devices:
return
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)
concept_device = ConceptDevice(
concept=concept,
name=(device.name or '') if include_device_names else '',
status=ConceptDeviceStatusChoices.STATUS_EXISTING,
device_type=device.device_type,
role=device.role,
platform=device.platform,
tenant=device.tenant,
position=device.position + shift,
face=device.face,
width_fraction=fraction,
horizontal_position=horizontal,
serial=device.serial if include_device_names else '',
source_device=device,
)
try:
concept_device.full_clean()
except ValidationError:
warnings.append(_(
'%(device)s from %(rack)s does not fit at its shifted position and was left out.'
) % {'device': device, 'rack': rack})
continue
concept_device.save()
_copy_from_rack(rack_bottom, 0)
_copy_from_rack(rack_top, bottom_height)
return concept, warnings
#
# RackConcept -> RackConcept
#
+80
View File
@@ -22,6 +22,7 @@ __all__ = (
'ConceptDeviceForm',
'ConceptDeviceImportForm',
'MergeConceptsForm',
'MergeRacksForm',
'RackConceptBulkEditForm',
'RackConceptCloneForm',
'RackConceptFilterForm',
@@ -422,6 +423,85 @@ class MergeConceptsForm(forms.Form):
return cleaned
class MergeRacksForm(forms.Form):
"""Combine two existing racks directly into one new, stacked blueprint."""
name = forms.CharField(
label=_('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')
)
rack_bottom = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label=_('Bottom rack'),
help_text=_('Keeps its planned positions unchanged')
)
rack_top = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label=_('Top rack'),
help_text=_("Its devices are shifted up by the bottom rack's height")
)
include_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Copy installed devices')
)
include_device_names = forms.BooleanField(
required=False,
initial=True,
label=_('Keep device names')
)
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('rack_bottom', 'rack_top', name=_('Sources')),
FieldSet('include_devices', 'include_device_names', name=_('Content')),
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('rack_bottom'), cleaned.get('rack_top')
if bottom and top and bottom.pk == top.pk:
raise forms.ValidationError(_('Choose two different racks to merge.'))
return cleaned
class RackConceptCloneForm(forms.Form):
"""Duplicate a blueprint, for building variants."""
name = forms.CharField(
@@ -41,19 +41,32 @@
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);
/* Label drawn over an image: a black stroke pass behind a white fill pass gives crisp,
readable text on any picture without a solid backing box. */
.concept-elevation .device-image-label {
font-size: 11px;
pointer-events: none;
}
.concept-elevation .device-image-label-stroke {
fill: none;
stroke: #000000;
stroke-width: 0.2em;
stroke-linejoin: round;
}
.concept-elevation .device-image-label-fill {
fill: #ffffff;
}
/* Toggled by the "images and labels / images only / labels only" selector. */
.concept-elevation .hidden {
visibility: hidden;
}
.concept-elevation .opposite-hint {
@@ -1,3 +1,50 @@
/*
* View selector: show/hide device images and their labels, mirroring the
* "images-and-labels / images-only / labels-only" control on NetBox's own rack
* elevation. The plain (non-image) label is never toggled by this - it's always in the
* markup, and an opaque image simply sits on top of it, exactly as NetBox does it.
* Runs independently of edit permissions, so it works for read-only users too.
*/
(function () {
'use strict';
const STORAGE_KEY = 'netbox_rack_concept:elevation-view';
const select = document.getElementById('concept-elevation-view');
const container = document.getElementById('concept-elevations');
if (!select || !container) {
return;
}
function applyView(view) {
const showImages = view !== 'labels-only';
const showImageLabels = view === 'images-and-labels';
container.querySelectorAll('image.device-image').forEach(function (el) {
el.classList.toggle('hidden', !showImages);
});
container.querySelectorAll('.device-image-label').forEach(function (el) {
el.classList.toggle('hidden', !showImageLabels);
});
}
let initial = 'images-and-labels';
try {
initial = window.localStorage.getItem(STORAGE_KEY) || initial;
} catch (e) {
// Storage may be unavailable (private browsing, blocked cookies); fall back silently.
}
select.value = initial;
applyView(initial);
select.addEventListener('change', function () {
applyView(select.value);
try {
window.localStorage.setItem(STORAGE_KEY, select.value);
} catch (e) {
// Nothing to do if storage isn't available - the choice just won't persist.
}
});
})();
/*
* Drag & drop for concept rack elevations.
*
@@ -0,0 +1,49 @@
/*
* "Merge into concept" button on the real dcim Rack list.
*
* The button lives outside NetBox's own bulk-action <form> (plugin list buttons render
* in the page header, the row checkboxes further down in the table's form), so it
* can't rely on a normal form submit to collect the selection. Instead it reads the
* checked "pk" checkboxes straight out of the DOM - the same input name NetBox's own
* bulk-edit/bulk-delete buttons use - and forwards up to two of them as query
* parameters to the merge page, which pre-fills its two rack fields from that.
*/
(function () {
'use strict';
const button = document.getElementById('concept-merge-racks-button');
if (!button) {
return;
}
function checkedRackIds() {
return Array.prototype.map.call(
document.querySelectorAll('input[name="pk"]:checked'),
function (el) { return el.value; }
);
}
button.addEventListener('click', function (event) {
const ids = checkedRackIds();
if (ids.length === 0) {
// Nothing selected: fall through to the plain link, let the user pick
// both racks by hand on the merge page instead.
return;
}
if (ids.length > 2) {
event.preventDefault();
window.alert(
'Please select exactly two racks to merge (you selected ' + ids.length + ').'
);
return;
}
event.preventDefault();
const url = new URL(button.dataset.mergeUrl, window.location.origin);
url.searchParams.set('rack_bottom', ids[0]);
if (ids.length === 2) {
url.searchParams.set('rack_top', ids[1]);
}
window.location.href = url.toString();
});
})();
+11 -1
View File
@@ -10,7 +10,10 @@ def _enabled():
class RackConceptButtons(PluginTemplateExtension):
"""Adds "Copy to concept" to the button row of a rack, and lists derived concepts."""
"""
Adds "Copy to concept" to the button row of a rack, "Merge into concept" to the
rack list's toolbar, and lists concepts derived from a rack.
"""
models = ['dcim.rack']
def buttons(self):
@@ -22,6 +25,13 @@ class RackConceptButtons(PluginTemplateExtension):
'rack': self.context['object'],
})
def list_buttons(self):
if not _enabled():
return ''
if not self.context['request'].user.has_perm('netbox_rack_concept.add_rackconcept'):
return ''
return self.render('netbox_rack_concept/inc/rack_list_merge_button.html')
def right_page(self):
if not _enabled():
return ''
@@ -32,33 +32,38 @@
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>
{% comment %}
The plain label is always in the markup, image or not - toggling "images
only" just layers the (opaque) image on top of it, exactly like NetBox's
own rack elevation does. That way there is one code path, not two.
{% endcomment %}
{% if block.show_text %}
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
fill="#{{ block.text_color }}" class="device-label">
{{ block.label }}{% if block.opposite %} <tspan class="opposite-hint">({% trans "rear" %})</tspan>{% endif %}
</text>
{% endif %}
{% 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>
<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>
{% if block.show_text %}
{# Outlined white-on-black text, drawn as a stroke pass then a fill pass, so it reads over any image without a background box. #}
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
class="device-image-label device-image-label-stroke">{{ block.label }}</text>
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
class="device-image-label device-image-label-fill">{{ block.label }}</text>
{% endif %}
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
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 %}
<title>{{ block.label }}{% if block.sublabel %} &middot; {{ block.sublabel }}{% endif %} &middot; U{{ block.device.position }}</title>
</a>
@@ -0,0 +1,8 @@
{% load i18n %}
{% load static %}
<a href="{% url 'plugins:netbox_rack_concept:racks_merge' %}" class="btn btn-outline-secondary"
id="concept-merge-racks-button" data-merge-url="{% url 'plugins:netbox_rack_concept:racks_merge' %}"
title="{% trans 'Select two racks below, then click this to combine them into one concept' %}">
<i class="mdi mdi-call-merge"></i> {% trans "Merge into concept" %}
</a>
<script src="{% static 'netbox_rack_concept/rack_list_merge.js' %}"></script>
@@ -128,12 +128,20 @@
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">
{% trans "Elevation" %}
{% if perms.netbox_rack_concept.change_conceptdevice %}
<span class="badge text-bg-secondary float-end">{% trans "Drag devices to move them" %}</span>
{% endif %}
</h2>
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h2 class="mb-0">{% trans "Elevation" %}</h2>
<div class="d-flex align-items-center gap-2">
<select id="concept-elevation-view" class="form-select form-select-sm" style="width: auto;"
aria-label="{% trans 'Images and labels' %}">
<option value="images-and-labels">{% trans "Images and labels" %}</option>
<option value="images-only">{% trans "Images only" %}</option>
<option value="labels-only">{% trans "Labels only" %}</option>
</select>
{% if perms.netbox_rack_concept.change_conceptdevice %}
<span class="badge text-bg-secondary">{% trans "Drag devices to move them" %}</span>
{% endif %}
</div>
</div>
<div class="card-body">
<div class="d-flex justify-content-around flex-wrap"
id="concept-elevations"
@@ -0,0 +1,28 @@
{% extends 'base/layout.html' %}
{% load i18n %}
{% load form_helpers %}
{% block title %}{% trans "Merge Racks into a Concept" %}{% 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 racks into one concept" %}</h2>
<div class="card-body">
<p class="text-muted">
{% trans "Snapshots both racks' devices into one new blueprint - the top rack's devices shifted up by the bottom rack's height. Neither rack 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 "Create concept" %}</button>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
+2 -1
View File
@@ -18,6 +18,7 @@ urlpatterns = [
path('devices/', include(get_model_urls('netbox_rack_concept', 'conceptdevice', detail=False))),
path('devices/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'conceptdevice'))),
# Entry point from a real rack
# Entry points from real racks
path('racks/<int:pk>/copy/', views.RackCopyView.as_view(), name='rack_copy'),
path('racks/merge/', views.MergeRacksToConceptView.as_view(), name='racks_merge'),
]
+55
View File
@@ -23,6 +23,7 @@ from .models import ConceptDevice, RackConcept
__all__ = (
'ConceptDeviceView',
'MergeConceptsView',
'MergeRacksToConceptView',
'RackConceptView',
)
@@ -285,6 +286,60 @@ class MergeConceptsView(ObjectPermissionRequiredMixin, View):
})
class MergeRacksToConceptView(ObjectPermissionRequiredMixin, View):
"""Combine two real racks directly into one new, stacked blueprint."""
queryset = RackConcept.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.add_rackconcept'
def get(self, request):
form = forms.MergeRacksForm(initial={
'rack_bottom': request.GET.get('rack_bottom'),
'rack_top': request.GET.get('rack_top'),
})
return render(request, 'netbox_rack_concept/racks_merge.html', {
'form': form,
'return_url': reverse('dcim:rack_list'),
})
def post(self, request):
form = forms.MergeRacksForm(request.POST)
if form.is_valid():
try:
concept, warnings = deploy.merge_racks_to_concept(
rack_bottom=form.cleaned_data['rack_bottom'],
rack_top=form.cleaned_data['rack_top'],
name=form.cleaned_data['name'],
status=form.cleaned_data['status'],
tenant=form.cleaned_data['tenant'],
tenant_group=form.cleaned_data['tenant_group'],
include_devices=form.cleaned_data['include_devices'],
include_device_names=form.cleaned_data['include_device_names'],
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 concept %(concept)s from %(bottom)s and %(top)s.') % {
'concept': concept,
'bottom': form.cleaned_data['rack_bottom'],
'top': form.cleaned_data['rack_top'],
})
return redirect(concept.get_absolute_url())
return render(request, 'netbox_rack_concept/racks_merge.html', {
'form': form,
'return_url': reverse('dcim:rack_list'),
})
@register_model_view(RackConcept, 'clone_concept', path='clone')
class RackConceptCloneView(ObjectPermissionRequiredMixin, View):
"""Duplicate a concept to build a variant."""