From bfc4382403f1a5a50edc01d80d6daff10ca68cf2 Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 17 Sep 2026 15:30:38 +0200 Subject: [PATCH] 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 --- netbox_utilities/__init__.py | 2 +- netbox_utilities/svg_export.py | 30 ++++++++++++++++++ .../rack_offline_svg_panel.html | 26 ++++++++++++++-- netbox_utilities/tests/test_svg_export.py | 31 ++++++++++++++++++- netbox_utilities/views.py | 8 +++-- pyproject.toml | 2 +- 6 files changed, 92 insertions(+), 7 deletions(-) diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index dbf66d0..743a75c 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -1,6 +1,6 @@ from netbox.plugins import PluginConfig, get_plugin_config -__version__ = "0.11.4" +__version__ = "0.12.0" class NetBoxUtilitiesConfig(PluginConfig): diff --git a/netbox_utilities/svg_export.py b/netbox_utilities/svg_export.py index aad2aaf..8ebce93 100644 --- a/netbox_utilities/svg_export.py +++ b/netbox_utilities/svg_export.py @@ -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 wrapper unwrapped and dead links removed. diff --git a/netbox_utilities/templates/netbox_utilities/rack_offline_svg_panel.html b/netbox_utilities/templates/netbox_utilities/rack_offline_svg_panel.html index d3a16fe..01575a7 100644 --- a/netbox_utilities/templates/netbox_utilities/rack_offline_svg_panel.html +++ b/netbox_utilities/templates/netbox_utilities/rack_offline_svg_panel.html @@ -2,14 +2,36 @@

SVG offline exportieren

+ diff --git a/netbox_utilities/tests/test_svg_export.py b/netbox_utilities/tests/test_svg_export.py index 24e8592..18665dd 100644 --- a/netbox_utilities/tests/test_svg_export.py +++ b/netbox_utilities/tests/test_svg_export.py @@ -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("' + 'USV 8' + '' + 'USV 8' + "" + ) + + 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) diff --git a/netbox_utilities/views.py b/netbox_utilities/views.py index 7b09cbe..84eb59e 100644 --- a/netbox_utilities/views.py +++ b/netbox_utilities/views.py @@ -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}"' diff --git a/pyproject.toml b/pyproject.toml index 03b999d..4c1f1f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"