feat: honor images-only/labels-only rack view mode in offline SVG export
The rack elevation page's "Bilder und Beschriftungen" dropdown only toggles CSS visibility client-side after the browser already has the SVG, so a freshly server-rendered export had no way to know which mode was selected and always exported everything. apply_rack_view_mode() replicates that filtering server-side by removing the .label text elements (images-only) or the .device-image / .device-image-label elements (labels-only) from the parsed SVG. RackElevationOfflineSVGView now accepts a ?view= query param, and the rack detail page panel syncs its download links to the page's own "select.rack-view" dropdown so the exported file matches what's currently shown. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from netbox.plugins import PluginConfig, get_plugin_config
|
||||
|
||||
__version__ = "0.11.4"
|
||||
__version__ = "0.12.0"
|
||||
|
||||
|
||||
class NetBoxUtilitiesConfig(PluginConfig):
|
||||
|
||||
@@ -10,6 +10,36 @@ _ANCHOR_TAGS = (f"{{{SVG_NS}}}a", "a")
|
||||
_IMAGE_TAGS = (f"{{{SVG_NS}}}image", "image")
|
||||
_HREF_ATTRS = ("href", f"{{{XLINK_NS}}}href")
|
||||
|
||||
RACK_VIEW_MODES = {"images-and-labels", "images-only", "labels-only"}
|
||||
_LABEL_CLASSES = {"label"}
|
||||
_IMAGE_CLASSES = {"device-image", "device-image-label"}
|
||||
|
||||
|
||||
def apply_rack_view_mode(svg_source, mode):
|
||||
"""Mirror the rack elevation page's "Bilder"/"Beschriftungen" toggle server-side.
|
||||
|
||||
That toggle only hides elements client-side via CSS after the browser
|
||||
already has the full SVG, so a freshly server-rendered export needs its
|
||||
own equivalent to honor the mode the user had selected.
|
||||
"""
|
||||
if mode not in RACK_VIEW_MODES or mode == "images-and-labels":
|
||||
return svg_source
|
||||
root = ET.fromstring(svg_source)
|
||||
if mode == "images-only":
|
||||
_remove_elements_with_class(root, _LABEL_CLASSES)
|
||||
elif mode == "labels-only":
|
||||
_remove_elements_with_class(root, _IMAGE_CLASSES)
|
||||
return ET.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _remove_elements_with_class(parent, class_names):
|
||||
for child in list(parent):
|
||||
classes = set(child.attrib.get("class", "").split())
|
||||
if classes & class_names:
|
||||
parent.remove(child)
|
||||
else:
|
||||
_remove_elements_with_class(child, class_names)
|
||||
|
||||
|
||||
def strip_svg_hyperlinks(svg_source, image_loader=None):
|
||||
"""Return svg_source with every <a> wrapper unwrapped and dead links removed.
|
||||
|
||||
@@ -2,14 +2,36 @@
|
||||
<h2 class="card-header">SVG offline exportieren</h2>
|
||||
<div class="card-body d-flex flex-column gap-2">
|
||||
<a href="{% url 'plugins:netbox_utilities:rack_elevation_offline_svg' pk=rack.pk %}?face=front"
|
||||
class="btn btn-outline-secondary" title="Vorderseite als linkfreie SVG-Datei herunterladen">
|
||||
class="btn btn-outline-secondary netbox-utilities-offline-svg-link" data-face="front"
|
||||
title="Vorderseite als linkfreie SVG-Datei herunterladen">
|
||||
<i class="mdi mdi-download" aria-hidden="true"></i>
|
||||
Vorderseite
|
||||
</a>
|
||||
<a href="{% url 'plugins:netbox_utilities:rack_elevation_offline_svg' pk=rack.pk %}?face=rear"
|
||||
class="btn btn-outline-secondary" title="Rückseite als linkfreie SVG-Datei herunterladen">
|
||||
class="btn btn-outline-secondary netbox-utilities-offline-svg-link" data-face="rear"
|
||||
title="Rückseite als linkfreie SVG-Datei herunterladen">
|
||||
<i class="mdi mdi-download" aria-hidden="true"></i>
|
||||
Rückseite
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var links = document.querySelectorAll(".netbox-utilities-offline-svg-link");
|
||||
var viewSelect = document.querySelector("select.rack-view");
|
||||
if (!links.length || !viewSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
function syncLinks() {
|
||||
links.forEach(function (link) {
|
||||
var url = new URL(link.href, window.location.origin);
|
||||
url.searchParams.set("view", viewSelect.value);
|
||||
link.href = url.toString();
|
||||
});
|
||||
}
|
||||
|
||||
viewSelect.addEventListener("change", syncLinks);
|
||||
syncLinks();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from netbox_utilities.svg_export import strip_svg_hyperlinks
|
||||
from netbox_utilities.svg_export import apply_rack_view_mode, strip_svg_hyperlinks
|
||||
|
||||
|
||||
class StripSvgHyperlinksTest(SimpleTestCase):
|
||||
@@ -91,3 +91,32 @@ class StripSvgHyperlinksTest(SimpleTestCase):
|
||||
|
||||
self.assertIn("<rect", result)
|
||||
self.assertNotIn("href", result)
|
||||
|
||||
|
||||
class ApplyRackViewModeTest(SimpleTestCase):
|
||||
SVG = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">'
|
||||
'<text class="label">USV 8</text>'
|
||||
'<image class="device-image"/>'
|
||||
'<text class="device-image-label">USV 8</text>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
def test_images_and_labels_leaves_svg_untouched(self):
|
||||
self.assertEqual(apply_rack_view_mode(self.SVG, "images-and-labels"), self.SVG)
|
||||
|
||||
def test_images_only_removes_label_text(self):
|
||||
result = apply_rack_view_mode(self.SVG, "images-only")
|
||||
|
||||
self.assertNotIn('class="label"', result)
|
||||
self.assertIn('class="device-image"', result)
|
||||
self.assertIn('class="device-image-label"', result)
|
||||
|
||||
def test_labels_only_removes_device_images(self):
|
||||
result = apply_rack_view_mode(self.SVG, "labels-only")
|
||||
|
||||
self.assertIn('class="label"', result)
|
||||
self.assertNotIn("device-image", result)
|
||||
|
||||
def test_unknown_mode_leaves_svg_untouched(self):
|
||||
self.assertEqual(apply_rack_view_mode(self.SVG, "bogus"), self.SVG)
|
||||
|
||||
@@ -36,7 +36,7 @@ from .runtime import (
|
||||
offline_svg_export_enabled,
|
||||
tenant_filter_enabled,
|
||||
)
|
||||
from .svg_export import strip_svg_hyperlinks
|
||||
from .svg_export import RACK_VIEW_MODES, apply_rack_view_mode, strip_svg_hyperlinks
|
||||
|
||||
|
||||
def _safe_return_url(request, default_name="home"):
|
||||
@@ -410,6 +410,9 @@ class RackElevationOfflineSVGView(LoginRequiredMixin, View):
|
||||
face = request.GET.get("face", "front")
|
||||
if face not in ("front", "rear"):
|
||||
return HttpResponseBadRequest("Ungültige Rack-Seite.")
|
||||
view_mode = request.GET.get("view", "images-and-labels")
|
||||
if view_mode not in RACK_VIEW_MODES:
|
||||
return HttpResponseBadRequest("Ungültige Ansicht.")
|
||||
|
||||
from dcim.api.views import RackViewSet
|
||||
from rest_framework.test import APIRequestFactory
|
||||
@@ -425,7 +428,8 @@ class RackElevationOfflineSVGView(LoginRequiredMixin, View):
|
||||
if hasattr(response, "render"):
|
||||
response.render()
|
||||
|
||||
offline_svg = strip_svg_hyperlinks(response.content, image_loader=_media_data_uri_loader())
|
||||
svg_content = apply_rack_view_mode(response.content, view_mode)
|
||||
offline_svg = strip_svg_hyperlinks(svg_content, image_loader=_media_data_uri_loader())
|
||||
filename = f"{rack.name}-{face}-offline.svg".replace(" ", "_")
|
||||
http_response = HttpResponse(offline_svg, content_type="image/svg+xml")
|
||||
http_response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "netbox-utilities"
|
||||
version = "0.11.4"
|
||||
version = "0.12.0"
|
||||
description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6/4.7"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
Reference in New Issue
Block a user