Add SVG/PNG/draw.io export for rack concepts, center the add-device hint
- Extract the elevation's <svg> 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 <canvas> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,11 @@ Dazu:
|
|||||||
unverkabelten Komponente wird sie mit einem Concept Cable verbunden. Ein „Add
|
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 —
|
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.
|
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,
|
* Vollständige REST-API, Filter, CSV-Import/-Export, Bulk-Edit, Changelog, Journal,
|
||||||
Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt.
|
Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt.
|
||||||
|
|
||||||
|
|||||||
@@ -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' <mxCell id="{cell_id}" value={_quote_attr(value)} style={_quote_attr(style)} '
|
||||||
|
f'vertex="1" parent="1">\n'
|
||||||
|
f' <mxGeometry x="{x:.2f}" y="{y:.2f}" width="{width:.2f}" height="{height:.2f}" '
|
||||||
|
f'as="geometry"/>\n'
|
||||||
|
f' </mxCell>'
|
||||||
|
)
|
||||||
|
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 += '<br>' + _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 (
|
||||||
|
'<mxfile host="netbox-rack-concept">\n'
|
||||||
|
f' <diagram id="concept-{concept.pk}" name={_quote_attr(concept.name)}>\n'
|
||||||
|
' <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" '
|
||||||
|
'arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">\n'
|
||||||
|
' <root>\n'
|
||||||
|
' <mxCell id="0"/>\n'
|
||||||
|
' <mxCell id="1" parent="0"/>\n'
|
||||||
|
f'{body}\n'
|
||||||
|
' </root>\n'
|
||||||
|
' </mxGraphModel>\n'
|
||||||
|
' </diagram>\n'
|
||||||
|
'</mxfile>\n'
|
||||||
|
)
|
||||||
@@ -116,6 +116,7 @@ class Elevation:
|
|||||||
self.rack_width = RACK_WIDTH
|
self.rack_width = RACK_WIDTH
|
||||||
self.rack_x = RACK_X
|
self.rack_x = RACK_X
|
||||||
self.rack_right = RACK_X + RACK_WIDTH
|
self.rack_right = RACK_X + RACK_WIDTH
|
||||||
|
self.rack_center_x = RACK_X + RACK_WIDTH / 2
|
||||||
self.width = RACK_X + RACK_WIDTH + MARGIN
|
self.width = RACK_X + RACK_WIDTH + MARGIN
|
||||||
self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2
|
self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* "Export as PNG" - fetches the same standalone SVG the SVG download link points to
|
||||||
|
* and rasterizes it client-side via <canvas>. 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(/<svg[^>]*\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 <style> block,
|
||||||
|
// which some rasterizers (older WebKit) ignore when drawing to canvas.
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
canvas.toBlob(function (blob) {
|
||||||
|
if (!blob) {
|
||||||
|
window.alert('The PNG could not be generated.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const downloadUrl = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(downloadUrl);
|
||||||
|
}, 'image/png');
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = function () {
|
||||||
|
URL.revokeObjectURL(svgUrl);
|
||||||
|
// Most likely a device type image from a different origin without
|
||||||
|
// permissive CORS headers, which taints the canvas and blocks toBlob().
|
||||||
|
window.alert('The PNG could not be generated. This can happen when a device '
|
||||||
|
+ 'type image is hosted on a different domain without CORS enabled.');
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = svgUrl;
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="{{ elevation.width }}" height="{{ elevation.height }}"
|
||||||
|
viewBox="0 0 {{ elevation.width }} {{ elevation.height }}">
|
||||||
|
<title>{{ elevation.concept.name }} - {{ elevation.face_label|title }}</title>
|
||||||
|
<style>
|
||||||
|
/* Concrete colours, not the on-page CSS custom properties: this file is meant to
|
||||||
|
be opened outside NetBox, where those variables don't exist. */
|
||||||
|
svg { background-color: #ffffff; font-family: -apple-system, "Segoe UI", Roboto, Arial, sans-serif; }
|
||||||
|
.unit-slot { fill: #f6f8fa; stroke: #dadfe5; stroke-width: 1; }
|
||||||
|
.unit-label { font-size: 10px; fill: #808f9c; }
|
||||||
|
.rack-frame { stroke: #35404a; stroke-width: 2; }
|
||||||
|
.device-rect { stroke: rgba(0, 0, 0, 0.35); stroke-width: 1; }
|
||||||
|
.device-label { font-size: 11px; }
|
||||||
|
.device-image-label { font-size: 11px; }
|
||||||
|
.device-image-label-stroke { fill: none; stroke: #000000; stroke-width: 0.2em; stroke-linejoin: round; }
|
||||||
|
.device-image-label-fill { fill: #ffffff; }
|
||||||
|
.opposite-hint { font-size: 9px; opacity: 0.8; }
|
||||||
|
.device-block.opposite .device-rect { stroke-dasharray: 3 2; }
|
||||||
|
.add-device-hint { display: none; }
|
||||||
|
</style>
|
||||||
|
{% include 'netbox_rack_concept/inc/elevation_svg_content.html' %}
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,90 +1,10 @@
|
|||||||
{% load i18n %}
|
{% 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 %}
|
|
||||||
<div class="concept-elevation" data-face="{{ elevation.face }}">
|
<div class="concept-elevation" data-face="{{ elevation.face }}">
|
||||||
<h5 class="text-center text-muted mb-2">{{ elevation.face_label|title }}</h5>
|
<h5 class="text-center text-muted mb-2">{{ elevation.face_label|title }}</h5>
|
||||||
<svg width="{{ elevation.width }}" height="{{ elevation.height }}"
|
<svg width="{{ elevation.width }}" height="{{ elevation.height }}"
|
||||||
viewBox="0 0 {{ elevation.width }} {{ elevation.height }}"
|
viewBox="0 0 {{ elevation.width }} {{ elevation.height }}"
|
||||||
class="concept-elevation-svg" role="img"
|
class="concept-elevation-svg" role="img"
|
||||||
aria-label="{% blocktrans with face=elevation.face_label name=elevation.concept.name %}{{ face }} elevation of {{ name }}{% endblocktrans %}">
|
aria-label="{% blocktrans with face=elevation.face_label name=elevation.concept.name %}{{ face }} elevation of {{ name }}{% endblocktrans %}">
|
||||||
|
{% include 'netbox_rack_concept/inc/elevation_svg_content.html' %}
|
||||||
{# 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 %}
|
|
||||||
<g class="unit">
|
|
||||||
{% if not unit.occupied and can_add_device %}
|
|
||||||
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ elevation.concept.pk }}&position={{ unit.number }}&face={{ elevation.face }}&return_url={{ elevation.concept.get_absolute_url }}">
|
|
||||||
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
|
|
||||||
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
|
|
||||||
class="unit-slot free"
|
|
||||||
data-unit="{{ unit.number }}"></rect>
|
|
||||||
<text x="{{ elevation.rack_right|add:'-6' }}" y="{{ unit.y }}" dy="15"
|
|
||||||
class="add-device-hint" text-anchor="end">+ {% trans "Add device" %}</text>
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
|
|
||||||
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
|
|
||||||
class="unit-slot"
|
|
||||||
data-unit="{{ unit.number }}"></rect>
|
|
||||||
{% endif %}
|
|
||||||
<text x="{{ elevation.rack_x }}" y="{{ unit.y }}" dx="-4" dy="15"
|
|
||||||
class="unit-label" text-anchor="end">{{ unit.number }}</text>
|
|
||||||
</g>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{# Devices #}
|
|
||||||
{% for block in elevation.blocks %}
|
|
||||||
<g class="device-block {% if block.opposite %}opposite{% endif %}"
|
|
||||||
data-device-id="{{ block.device.pk }}"
|
|
||||||
data-position="{{ block.device.position }}"
|
|
||||||
data-u-height="{{ block.device.effective_u_height }}"
|
|
||||||
data-width-fraction="{{ block.device.width_fraction }}">
|
|
||||||
<a href="{{ block.device.get_absolute_url }}">
|
|
||||||
<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>
|
|
||||||
{% 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 %}
|
|
||||||
{% endif %}
|
|
||||||
<title>{{ block.label }}{% if block.sublabel %} · {{ block.sublabel }}{% endif %} · U{{ block.device.position }}</title>
|
|
||||||
</a>
|
|
||||||
</g>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<rect x="{{ elevation.rack_x }}" y="6"
|
|
||||||
width="{{ elevation.rack_width }}" height="{{ elevation.height|add:'-12' }}"
|
|
||||||
class="rack-frame" fill="none"></rect>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{% endlocalize %}
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load l10n %}
|
||||||
|
{% comment %}
|
||||||
|
Everything inside one face's <svg>...</svg> - 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 %}
|
||||||
|
<g class="unit">
|
||||||
|
{% if not unit.occupied and can_add_device %}
|
||||||
|
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ elevation.concept.pk }}&position={{ unit.number }}&face={{ elevation.face }}&return_url={{ elevation.concept.get_absolute_url }}">
|
||||||
|
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
|
||||||
|
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
|
||||||
|
class="unit-slot free"
|
||||||
|
data-unit="{{ unit.number }}"></rect>
|
||||||
|
<text x="{{ elevation.rack_center_x }}" y="{{ unit.y }}" dy="15"
|
||||||
|
class="add-device-hint" text-anchor="middle">+ {% trans "Add device" %}</text>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
|
||||||
|
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
|
||||||
|
class="unit-slot"
|
||||||
|
data-unit="{{ unit.number }}"></rect>
|
||||||
|
{% endif %}
|
||||||
|
<text x="{{ elevation.rack_x }}" y="{{ unit.y }}" dx="-4" dy="15"
|
||||||
|
class="unit-label" text-anchor="end">{{ unit.number }}</text>
|
||||||
|
</g>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Devices #}
|
||||||
|
{% for block in elevation.blocks %}
|
||||||
|
<g class="device-block {% if block.opposite %}opposite{% endif %}"
|
||||||
|
data-device-id="{{ block.device.pk }}"
|
||||||
|
data-position="{{ block.device.position }}"
|
||||||
|
data-u-height="{{ block.device.effective_u_height }}"
|
||||||
|
data-width-fraction="{{ block.device.width_fraction }}">
|
||||||
|
<a href="{{ block.device.get_absolute_url }}">
|
||||||
|
<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>
|
||||||
|
{# 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). #}
|
||||||
|
<image href="{% if request %}{{ request.scheme }}://{{ request.get_host }}{% endif %}{{ 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>
|
||||||
|
{% 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 %}
|
||||||
|
{% endif %}
|
||||||
|
<title>{{ block.label }}{% if block.sublabel %} · {{ block.sublabel }}{% endif %} · U{{ block.device.position }}</title>
|
||||||
|
</a>
|
||||||
|
</g>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<rect x="{{ elevation.rack_x }}" y="6"
|
||||||
|
width="{{ elevation.rack_width }}" height="{{ elevation.height|add:'-12' }}"
|
||||||
|
class="rack-frame" fill="none"></rect>
|
||||||
|
{% endlocalize %}
|
||||||
@@ -140,6 +140,37 @@
|
|||||||
{% if perms.netbox_rack_concept.change_conceptdevice %}
|
{% if perms.netbox_rack_concept.change_conceptdevice %}
|
||||||
<span class="badge text-bg-secondary">{% trans "Drag devices to move them" %}</span>
|
<span class="badge text-bg-secondary">{% trans "Drag devices to move them" %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="dropdown">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="mdi mdi-download"></i> {% trans "Export" %}
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><h6 class="dropdown-header">{% trans "Front" %}</h6></li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:rackconcept_export_svg' pk=object.pk %}?face=front">SVG</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item concept-export-png" href="#"
|
||||||
|
data-svg-url="{% url 'plugins:netbox_rack_concept:rackconcept_export_svg' pk=object.pk %}?face=front"
|
||||||
|
data-filename="{{ object.name|slugify }}-front.png">PNG</a>
|
||||||
|
</li>
|
||||||
|
<li><h6 class="dropdown-header">{% trans "Rear" %}</h6></li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:rackconcept_export_svg' pk=object.pk %}?face=rear">SVG</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item concept-export-png" href="#"
|
||||||
|
data-svg-url="{% url 'plugins:netbox_rack_concept:rackconcept_export_svg' pk=object.pk %}?face=rear"
|
||||||
|
data-filename="{{ object.name|slugify }}-rear.png">PNG</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:rackconcept_export_drawio' pk=object.pk %}">
|
||||||
|
{% trans "Draw.io (.drawio)" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -172,4 +203,5 @@
|
|||||||
{% block javascript %}
|
{% block javascript %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
<script src="{% static 'netbox_rack_concept/elevation.js' %}"></script>
|
<script src="{% static 'netbox_rack_concept/elevation.js' %}"></script>
|
||||||
|
<script src="{% static 'netbox_rack_concept/export.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,18 +6,22 @@ from django.conf import settings
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Count
|
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.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.template.loader import render_to_string
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
|
|
||||||
|
from dcim.choices import DeviceFaceChoices
|
||||||
from dcim.models import Rack
|
from dcim.models import Rack
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.views import ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
from utilities.views import ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
||||||
|
|
||||||
from . import components, deploy, filtersets, forms, tables
|
from . import components, deploy, filtersets, forms, tables
|
||||||
from .choices import ConceptComponentTypeChoices
|
from .choices import ConceptComponentTypeChoices
|
||||||
|
from .drawio import build_drawio
|
||||||
from .elevation import get_elevations
|
from .elevation import get_elevations
|
||||||
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
|
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')
|
@register_model_view(RackConcept, 'devices')
|
||||||
class RackConceptDevicesView(generic.ObjectChildrenView):
|
class RackConceptDevicesView(generic.ObjectChildrenView):
|
||||||
queryset = RackConcept.objects.all()
|
queryset = RackConcept.objects.all()
|
||||||
|
|||||||
Reference in New Issue
Block a user