From e4817ea83b834086c93d3476eecdd9795d412789 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 16 Sep 2026 09:50:23 +0200 Subject: [PATCH] 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 --- README.md | 7 +- netbox_rack_concept/deploy.py | 96 +++++++++++++++++-- netbox_rack_concept/forms.py | 80 ++++++++++++++++ .../static/netbox_rack_concept/elevation.css | 29 ++++-- .../static/netbox_rack_concept/elevation.js | 47 +++++++++ .../netbox_rack_concept/rack_list_merge.js | 49 ++++++++++ netbox_rack_concept/template_content.py | 12 ++- .../netbox_rack_concept/inc/elevation.html | 35 ++++--- .../inc/rack_list_merge_button.html | 8 ++ .../netbox_rack_concept/rackconcept.html | 20 ++-- .../netbox_rack_concept/racks_merge.html | 28 ++++++ netbox_rack_concept/urls.py | 3 +- netbox_rack_concept/views.py | 55 +++++++++++ 13 files changed, 430 insertions(+), 39 deletions(-) create mode 100644 netbox_rack_concept/static/netbox_rack_concept/rack_list_merge.js create mode 100644 netbox_rack_concept/templates/netbox_rack_concept/inc/rack_list_merge_button.html create mode 100644 netbox_rack_concept/templates/netbox_rack_concept/racks_merge.html diff --git a/README.md b/README.md index 055adef..d1738c9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/netbox_rack_concept/deploy.py b/netbox_rack_concept/deploy.py index e16f4dd..3e21b9f 100644 --- a/netbox_rack_concept/deploy.py +++ b/netbox_rack_concept/deploy.py @@ -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 # diff --git a/netbox_rack_concept/forms.py b/netbox_rack_concept/forms.py index 5c09d78..4caf9a1 100644 --- a/netbox_rack_concept/forms.py +++ b/netbox_rack_concept/forms.py @@ -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( diff --git a/netbox_rack_concept/static/netbox_rack_concept/elevation.css b/netbox_rack_concept/static/netbox_rack_concept/elevation.css index ab5dbd4..e632320 100644 --- a/netbox_rack_concept/static/netbox_rack_concept/elevation.css +++ b/netbox_rack_concept/static/netbox_rack_concept/elevation.css @@ -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 { diff --git a/netbox_rack_concept/static/netbox_rack_concept/elevation.js b/netbox_rack_concept/static/netbox_rack_concept/elevation.js index 33ea6d7..3b601a3 100644 --- a/netbox_rack_concept/static/netbox_rack_concept/elevation.js +++ b/netbox_rack_concept/static/netbox_rack_concept/elevation.js @@ -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. * diff --git a/netbox_rack_concept/static/netbox_rack_concept/rack_list_merge.js b/netbox_rack_concept/static/netbox_rack_concept/rack_list_merge.js new file mode 100644 index 0000000..80ed2a0 --- /dev/null +++ b/netbox_rack_concept/static/netbox_rack_concept/rack_list_merge.js @@ -0,0 +1,49 @@ +/* + * "Merge into concept" button on the real dcim Rack list. + * + * The button lives outside NetBox's own bulk-action
(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(); + }); +})(); diff --git a/netbox_rack_concept/template_content.py b/netbox_rack_concept/template_content.py index d57b8e0..d6aaea1 100644 --- a/netbox_rack_concept/template_content.py +++ b/netbox_rack_concept/template_content.py @@ -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 '' diff --git a/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html b/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html index 9d8ffb9..f94d78b 100644 --- a/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html +++ b/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html @@ -32,33 +32,38 @@ data-u-height="{{ block.device.effective_u_height }}" data-width-fraction="{{ block.device.width_fraction }}"> - {% if block.image %} - - - - {% endif %} + {% 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 %} + + {{ block.label }}{% if block.opposite %} ({% trans "rear" %}){% endif %} + + {% endif %} {% if block.image %} + + + - {% endif %} - {% if block.show_text %} - {% if block.image %} - + {% 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. #} + {{ block.label }} + {{ block.label }} {% endif %} - - {{ block.label }}{% if block.opposite %} ({% trans "rear" %}){% endif %} - {% endif %} {{ block.label }}{% if block.sublabel %} · {{ block.sublabel }}{% endif %} · U{{ block.device.position }} diff --git a/netbox_rack_concept/templates/netbox_rack_concept/inc/rack_list_merge_button.html b/netbox_rack_concept/templates/netbox_rack_concept/inc/rack_list_merge_button.html new file mode 100644 index 0000000..4055023 --- /dev/null +++ b/netbox_rack_concept/templates/netbox_rack_concept/inc/rack_list_merge_button.html @@ -0,0 +1,8 @@ +{% load i18n %} +{% load static %} + + {% trans "Merge into concept" %} + + diff --git a/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html b/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html index 7d2809f..3f7b36d 100644 --- a/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html +++ b/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html @@ -128,12 +128,20 @@
-

- {% trans "Elevation" %} - {% if perms.netbox_rack_concept.change_conceptdevice %} - {% trans "Drag devices to move them" %} - {% endif %} -

+
+

{% trans "Elevation" %}

+
+ + {% if perms.netbox_rack_concept.change_conceptdevice %} + {% trans "Drag devices to move them" %} + {% endif %} +
+
+ {% csrf_token %} +
+
+
+

{% trans "Merge two racks into one concept" %}

+
+

+ {% 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." %} +

+ {% render_form form %} +
+ +
+
+
+ +{% endblock %} diff --git a/netbox_rack_concept/urls.py b/netbox_rack_concept/urls.py index 599391d..2a7fa68 100644 --- a/netbox_rack_concept/urls.py +++ b/netbox_rack_concept/urls.py @@ -18,6 +18,7 @@ urlpatterns = [ path('devices/', include(get_model_urls('netbox_rack_concept', 'conceptdevice', detail=False))), path('devices//', include(get_model_urls('netbox_rack_concept', 'conceptdevice'))), - # Entry point from a real rack + # Entry points from real racks path('racks//copy/', views.RackCopyView.as_view(), name='rack_copy'), + path('racks/merge/', views.MergeRacksToConceptView.as_view(), name='racks_merge'), ] diff --git a/netbox_rack_concept/views.py b/netbox_rack_concept/views.py index a771f83..9c7b05f 100644 --- a/netbox_rack_concept/views.py +++ b/netbox_rack_concept/views.py @@ -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."""