From 2c991e37ae1e323353968692a32f7c4a0fcf870a Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 16 Sep 2026 12:34:26 +0200 Subject: [PATCH] Add SVG/PNG/draw.io export for rack concepts, center the add-device hint - Extract the elevation's body into inc/elevation_svg_content.html, shared by the on-page elevation and a new standalone export template so they never drift apart. - Add "Export" dropdown above the elevation: Front/Rear as standalone SVG (self-contained inline styles, no dependency on the page's CSS, absolute device-type image URLs so the file still works once saved elsewhere), Front/Rear as PNG (rasterized entirely client-side via from that same SVG - no new server-side image dependency), and the whole concept as a draw.io (.drawio / mxGraph XML) diagram with both faces side by side, reusing the identical block geometry. - Center the "+ Add device" hint on empty units instead of right-aligning it. Co-Authored-By: Claude Sonnet 5 --- README.md | 5 + netbox_rack_concept/drawio.py | 112 ++++++++++++++++++ netbox_rack_concept/elevation.py | 1 + .../static/netbox_rack_concept/export.js | 83 +++++++++++++ .../netbox_rack_concept/elevation_export.html | 23 ++++ .../netbox_rack_concept/inc/elevation.html | 82 +------------ .../inc/elevation_svg_content.html | 86 ++++++++++++++ .../netbox_rack_concept/rackconcept.html | 32 +++++ netbox_rack_concept/views.py | 45 ++++++- 9 files changed, 387 insertions(+), 82 deletions(-) create mode 100644 netbox_rack_concept/drawio.py create mode 100644 netbox_rack_concept/static/netbox_rack_concept/export.js create mode 100644 netbox_rack_concept/templates/netbox_rack_concept/elevation_export.html create mode 100644 netbox_rack_concept/templates/netbox_rack_concept/inc/elevation_svg_content.html diff --git a/README.md b/README.md index 5000021..351eb0a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,11 @@ Dazu: unverkabelten Komponente wird sie mit einem Concept Cable verbunden. Ein „Add Components"-Dropdown (genau wie am echten Gerät) legt neue Komponenten pro Typ an — nötig für Platzhalter ohne Gerätetyp, die nichts zum Syncen haben. +* **Export** – Button **„Export"** über der Elevation: Front bzw. Rear jeweils als + eigenständige **SVG**- oder **PNG**-Datei, sowie das ganze Konzept (beide Seiten) als + **Draw.io**-Diagramm (`.drawio`, per Datei > Öffnen oder Extras > Diagramm bearbeiten + in diagrams.net einlesbar). PNG wird komplett im Browser aus dem SVG gerastert — + dafür ist keine zusätzliche Bildbibliothek auf dem Server nötig. * Vollständige REST-API, Filter, CSV-Import/-Export, Bulk-Edit, Changelog, Journal, Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt. diff --git a/netbox_rack_concept/drawio.py b/netbox_rack_concept/drawio.py new file mode 100644 index 0000000..6d4723e --- /dev/null +++ b/netbox_rack_concept/drawio.py @@ -0,0 +1,112 @@ +""" +Export a rack concept as a draw.io (diagrams.net) diagram. + +Reuses the same Elevation/ElevationBlock geometry the on-page SVG and the SVG export +are built from, so all three always agree on where everything sits. The output is a +plain mxGraph XML file - drawio can open it directly (File > Open) or import it +(Extras > Edit Diagram accepts the same XML pasted in). +""" +import html as html_lib + +from .elevation import get_elevations + +__all__ = ('build_drawio',) + +LABEL_HEIGHT = 30 +FACE_GAP = 60 + + +def _escape(text): + """HTML-escape for use inside an html=1 cell value (which is itself XML-escaped + again by the caller) - two layers, same as any HTML embedded in an XML attribute.""" + return html_lib.escape(text or '', quote=False) + + +def _quote_attr(value): + """XML-attribute-escape a string, matching xml.sax.saxutils.quoteattr but always + using double quotes so it's safe to splice straight into our hand-built XML.""" + return '"' + ( + value.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') + ) + '"' + + +class _CellBuilder: + """Accumulates mxCell XML fragments and hands out fresh ids.""" + + def __init__(self): + self._next_id = 1 + self.cells = [] + + def _id(self): + self._next_id += 1 + return f'c{self._next_id}' + + def add(self, value, style, x, y, width, height): + cell_id = self._id() + self.cells.append( + f' \n' + f' \n' + f' ' + ) + return cell_id + + +def _device_style(block): + style = ( + f'rounded=1;whiteSpace=wrap;html=1;arcSize=8;' + f'fillColor=#{block.color};fontColor=#{block.text_color};fontSize=11;' + ) + if block.opposite: + style += 'dashed=1;opacity=45;' + return style + + +def build_drawio(concept): + front, rear = get_elevations(concept) + builder = _CellBuilder() + + offset_x = 0.0 + for elevation in (front, rear): + builder.add( + _escape(elevation.face_label.title()), + 'text;html=1;align=center;verticalAlign=middle;fontStyle=1;fontSize=14;', + offset_x, 0, elevation.width, LABEL_HEIGHT - 4 + ) + builder.add( + '', 'rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#35404A;strokeWidth=2;', + offset_x + elevation.rack_x, LABEL_HEIGHT + 6, elevation.rack_width, elevation.height - 12 + ) + for unit in elevation.units: + builder.add( + str(unit.number), + 'text;html=1;align=right;verticalAlign=middle;fontSize=10;fontColor=#808F9C;spacingRight=4;', + offset_x, LABEL_HEIGHT + unit.y, elevation.rack_x - 4, unit.height + ) + for block in elevation.blocks: + label = _escape(block.label) + if block.sublabel: + label += '
' + _escape(block.sublabel) + builder.add( + label, _device_style(block), + offset_x + block.x, LABEL_HEIGHT + block.y, block.width, block.height + ) + offset_x += elevation.width + FACE_GAP + + body = '\n'.join(builder.cells) + + return ( + '\n' + f' \n' + ' \n' + ' \n' + ' \n' + ' \n' + f'{body}\n' + ' \n' + ' \n' + ' \n' + '\n' + ) diff --git a/netbox_rack_concept/elevation.py b/netbox_rack_concept/elevation.py index e7683ed..e28d53e 100644 --- a/netbox_rack_concept/elevation.py +++ b/netbox_rack_concept/elevation.py @@ -116,6 +116,7 @@ class Elevation: self.rack_width = RACK_WIDTH self.rack_x = RACK_X self.rack_right = RACK_X + RACK_WIDTH + self.rack_center_x = RACK_X + RACK_WIDTH / 2 self.width = RACK_X + RACK_WIDTH + MARGIN self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2 diff --git a/netbox_rack_concept/static/netbox_rack_concept/export.js b/netbox_rack_concept/static/netbox_rack_concept/export.js new file mode 100644 index 0000000..77ef343 --- /dev/null +++ b/netbox_rack_concept/static/netbox_rack_concept/export.js @@ -0,0 +1,83 @@ +/* + * "Export as PNG" - fetches the same standalone SVG the SVG download link points to + * and rasterizes it client-side via . Deliberately no server-side image + * library involved: a NetBox plugin shouldn't need one just for this, and every + * browser can already turn an SVG into a PNG on its own. + */ +(function () { + 'use strict'; + + const SCALE = 2; // render at 2x so the PNG stays crisp + + document.querySelectorAll('.concept-export-png').forEach(function (link) { + link.addEventListener('click', function (event) { + event.preventDefault(); + exportPng(link.dataset.svgUrl, link.dataset.filename || 'export.png'); + }); + }); + + function exportPng(svgUrl, filename) { + fetch(svgUrl, { credentials: 'same-origin' }) + .then(function (response) { + if (!response.ok) { + throw new Error('Could not load the SVG (' + response.status + ').'); + } + return response.text(); + }) + .then(function (svgText) { + rasterize(svgText, filename); + }) + .catch(function () { + window.alert('The PNG could not be generated.'); + }); + } + + function rasterize(svgText, filename) { + const sizeMatch = svgText.match(/]*\swidth="([\d.]+)"[^>]*\sheight="([\d.]+)"/); + const width = sizeMatch ? parseFloat(sizeMatch[1]) : 800; + const height = sizeMatch ? parseFloat(sizeMatch[2]) : 600; + + const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); + const svgUrl = URL.createObjectURL(svgBlob); + const img = new Image(); + + img.onload = function () { + URL.revokeObjectURL(svgUrl); + + const canvas = document.createElement('canvas'); + canvas.width = width * SCALE; + canvas.height = height * SCALE; + const ctx = canvas.getContext('2d'); + // The SVG itself has a white background, but that's inside a + {% include 'netbox_rack_concept/inc/elevation_svg_content.html' %} + 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 be79a52..cf67890 100644 --- a/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html +++ b/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html @@ -1,90 +1,10 @@ {% load i18n %} -{% load l10n %} -{% comment %} - One face of a concept rack. Localization is switched off below because SVG - coordinates must use a dot as the decimal separator whatever the UI language is. -{% endcomment %} -{% localize off %} -{% endlocalize %} diff --git a/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation_svg_content.html b/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation_svg_content.html new file mode 100644 index 0000000..7f2a20f --- /dev/null +++ b/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation_svg_content.html @@ -0,0 +1,86 @@ +{% load i18n %} +{% load l10n %} +{% comment %} + Everything inside one face's ... - shared by the on-page elevation + (inc/elevation.html) and the standalone SVG export (elevation_export.html), so the + two never drift apart. Localization is switched off because SVG coordinates must use + a dot as the decimal separator whatever the UI language is. +{% endcomment %} +{% localize off %} +{# Unit rows and legend. An empty unit becomes a click target for adding a device + here, exactly like a real rack's elevation. #} +{% for unit in elevation.units %} + + {% if not unit.occupied and can_add_device %} + + + + {% trans "Add device" %} + + {% else %} + + {% endif %} + {{ unit.number }} + +{% endfor %} + +{# Devices #} +{% for block in elevation.blocks %} + + + + {% 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 %} + + + + {# Absolute when we have a request (the export - a file that outlives the page + it came from needs a URL that still resolves once it's saved elsewhere). #} + + {% 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 %} + {% endif %} + {{ block.label }}{% if block.sublabel %} · {{ block.sublabel }}{% endif %} · U{{ block.device.position }} + + +{% endfor %} + + +{% endlocalize %} diff --git a/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html b/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html index 08f6436..5bef258 100644 --- a/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html +++ b/netbox_rack_concept/templates/netbox_rack_concept/rackconcept.html @@ -140,6 +140,37 @@ {% if perms.netbox_rack_concept.change_conceptdevice %} {% trans "Drag devices to move them" %} {% endif %} +
@@ -172,4 +203,5 @@ {% block javascript %} {{ block.super }} + {% endblock %} diff --git a/netbox_rack_concept/views.py b/netbox_rack_concept/views.py index 470de39..53f5b36 100644 --- a/netbox_rack_concept/views.py +++ b/netbox_rack_concept/views.py @@ -6,18 +6,22 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import Count -from django.http import HttpResponseForbidden, JsonResponse +from django.http import HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import get_object_or_404, redirect, render +from django.template.loader import render_to_string from django.urls import reverse +from django.utils.text import slugify from django.utils.translation import gettext as _ from django.views.generic import View +from dcim.choices import DeviceFaceChoices from dcim.models import Rack from netbox.views import generic from utilities.views import ObjectPermissionRequiredMixin, ViewTab, register_model_view from . import components, deploy, filtersets, forms, tables from .choices import ConceptComponentTypeChoices +from .drawio import build_drawio from .elevation import get_elevations from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept @@ -62,6 +66,45 @@ class RackConceptView(generic.ObjectView): } +@register_model_view(RackConcept, 'export_svg', path='export/svg') +class RackConceptExportSVGView(ObjectPermissionRequiredMixin, View): + """Download one face's elevation as a standalone SVG file.""" + queryset = RackConcept.objects.all() + + def get_required_permission(self): + return 'netbox_rack_concept.view_rackconcept' + + def get(self, request, pk): + concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk) + face = request.GET.get('face') + if face not in (DeviceFaceChoices.FACE_FRONT, DeviceFaceChoices.FACE_REAR): + face = DeviceFaceChoices.FACE_FRONT + + front, rear = get_elevations(concept) + elevation = front if face == DeviceFaceChoices.FACE_FRONT else rear + + svg = render_to_string('netbox_rack_concept/elevation_export.html', {'elevation': elevation}, request=request) + response = HttpResponse(svg, content_type='image/svg+xml') + response['Content-Disposition'] = f'attachment; filename="{slugify(concept.name)}-{face}.svg"' + return response + + +@register_model_view(RackConcept, 'export_drawio', path='export/drawio') +class RackConceptExportDrawioView(ObjectPermissionRequiredMixin, View): + """Download both faces of a concept as one draw.io (diagrams.net) diagram.""" + queryset = RackConcept.objects.all() + + def get_required_permission(self): + return 'netbox_rack_concept.view_rackconcept' + + def get(self, request, pk): + concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk) + xml = build_drawio(concept) + response = HttpResponse(xml, content_type='application/xml') + response['Content-Disposition'] = f'attachment; filename="{slugify(concept.name)}.drawio"' + return response + + @register_model_view(RackConcept, 'devices') class RackConceptDevicesView(generic.ObjectChildrenView): queryset = RackConcept.objects.all()