From 3768417be6c2f1349e7d33782ce69204eecaa609 Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 17 Sep 2026 14:22:04 +0200 Subject: [PATCH] feat: add offline SVG export for rack elevations Adds a "SVG offline" download button on the rack detail page. It reuses NetBox's own rack-elevation REST endpoint to render the SVG, then strips embedded /xlink:href links (which point at internal, relative NetBox URLs and are useless once the file is saved) so the result renders identically when opened standalone in any SVG viewer. Gated by the new offline_svg_export_enabled plugin setting (on by default). Co-Authored-By: Claude Sonnet 5 --- netbox_utilities/__init__.py | 3 +- netbox_utilities/runtime.py | 4 ++ netbox_utilities/svg_export.py | 48 ++++++++++++++++ netbox_utilities/template_content.py | 27 ++++++++- .../rack_offline_svg_buttons.html | 10 ++++ netbox_utilities/tests/test_svg_export.py | 55 +++++++++++++++++++ netbox_utilities/urls.py | 5 ++ netbox_utilities/views.py | 38 ++++++++++++- pyproject.toml | 2 +- 9 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 netbox_utilities/svg_export.py create mode 100644 netbox_utilities/templates/netbox_utilities/rack_offline_svg_buttons.html create mode 100644 netbox_utilities/tests/test_svg_export.py diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 0b23f38..c6b0eb9 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.10.4" +__version__ = "0.11.0" class NetBoxUtilitiesConfig(PluginConfig): @@ -19,6 +19,7 @@ class NetBoxUtilitiesConfig(PluginConfig): "topology_views_rack_width_enabled": True, "tenant_filter_enabled": True, "tenant_required": True, + "offline_svg_export_enabled": True, } middleware = [ "netbox_utilities.middleware.GlobalTenantFilterMiddleware", diff --git a/netbox_utilities/runtime.py b/netbox_utilities/runtime.py index c2489f5..362cd0f 100644 --- a/netbox_utilities/runtime.py +++ b/netbox_utilities/runtime.py @@ -15,6 +15,10 @@ def connection_vlans_enabled(): return bool(get_plugin_config("netbox_utilities", "connection_vlans_enabled")) +def offline_svg_export_enabled(): + return bool(get_plugin_config("netbox_utilities", "offline_svg_export_enabled")) + + def tenant_filter_enabled(): if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"): return False diff --git a/netbox_utilities/svg_export.py b/netbox_utilities/svg_export.py new file mode 100644 index 0000000..c9bacf0 --- /dev/null +++ b/netbox_utilities/svg_export.py @@ -0,0 +1,48 @@ +import xml.etree.ElementTree as ET + +SVG_NS = "http://www.w3.org/2000/svg" +XLINK_NS = "http://www.w3.org/1999/xlink" + +ET.register_namespace("", SVG_NS) +ET.register_namespace("xlink", XLINK_NS) + +_ANCHOR_TAGS = (f"{{{SVG_NS}}}a", "a") +_HREF_ATTRS = ("href", f"{{{XLINK_NS}}}href") + + +def strip_svg_hyperlinks(svg_source): + """Return svg_source with every wrapper unwrapped and href/xlink:href removed. + + NetBox wraps rack elevation devices in hyperlinks pointing at internal, + relative object URLs. Those links are dead weight (and broken paths) once + the SVG is saved and opened outside of a NetBox session, so this produces + a self-contained file with the same visuals but no link markup. + """ + root = ET.fromstring(svg_source) + _unwrap_anchors(root) + _strip_href_attributes(root) + return ET.tostring(root, encoding="unicode") + + +def _unwrap_anchors(parent): + replaced = False + new_children = [] + for child in list(parent): + _unwrap_anchors(child) + if child.tag in _ANCHOR_TAGS: + replaced = True + new_children.extend(list(child)) + else: + new_children.append(child) + if replaced: + for child in list(parent): + parent.remove(child) + for child in new_children: + parent.append(child) + + +def _strip_href_attributes(element): + for attr in _HREF_ATTRS: + element.attrib.pop(attr, None) + for child in element: + _strip_href_attributes(child) diff --git a/netbox_utilities/template_content.py b/netbox_utilities/template_content.py index 9cbfad1..86897ab 100644 --- a/netbox_utilities/template_content.py +++ b/netbox_utilities/template_content.py @@ -19,7 +19,12 @@ from .navigation_helpers import ( normalize_preferences, normalize_sidebar_width, ) -from .runtime import connection_vlans_enabled, navigation_customization_enabled, tenant_filter_enabled +from .runtime import ( + connection_vlans_enabled, + navigation_customization_enabled, + offline_svg_export_enabled, + tenant_filter_enabled, +) class UtilitiesGlobalContent(PluginTemplateExtension): @@ -147,4 +152,22 @@ class ConnectionVLANContent(PluginTemplateExtension): ) -template_extensions = [UtilitiesGlobalContent, DeviceUtilitiesContent, ConnectionVLANContent] +class RackUtilitiesContent(PluginTemplateExtension): + models = ["dcim.rack"] + + def buttons(self): + if not offline_svg_export_enabled(): + return "" + rack = self.context["object"] + return self.render( + "netbox_utilities/rack_offline_svg_buttons.html", + {"rack": rack}, + ) + + +template_extensions = [ + UtilitiesGlobalContent, + DeviceUtilitiesContent, + ConnectionVLANContent, + RackUtilitiesContent, +] diff --git a/netbox_utilities/templates/netbox_utilities/rack_offline_svg_buttons.html b/netbox_utilities/templates/netbox_utilities/rack_offline_svg_buttons.html new file mode 100644 index 0000000..4ae3c48 --- /dev/null +++ b/netbox_utilities/templates/netbox_utilities/rack_offline_svg_buttons.html @@ -0,0 +1,10 @@ + + + SVG offline (vorne) + + + + SVG offline (hinten) + diff --git a/netbox_utilities/tests/test_svg_export.py b/netbox_utilities/tests/test_svg_export.py new file mode 100644 index 0000000..24e9e5a --- /dev/null +++ b/netbox_utilities/tests/test_svg_export.py @@ -0,0 +1,55 @@ +from django.test import SimpleTestCase + +from netbox_utilities.svg_export import strip_svg_hyperlinks + + +class StripSvgHyperlinksTest(SimpleTestCase): + def test_unwraps_anchor_elements_keeping_children(self): + svg = ( + '' + '' + '' + "" + "" + ) + + result = strip_svg_hyperlinks(svg) + + self.assertNotIn("' + '' + "" + ) + + result = strip_svg_hyperlinks(svg) + + self.assertNotIn("href", result) + self.assertIn("' + '' + '' + '' + "" + ) + + result = strip_svg_hyperlinks(svg) + + self.assertLess(result.index('id="before"'), result.index('id="linked"')) + self.assertLess(result.index('id="linked"'), result.index('id="after"')) + + def test_leaves_svg_without_links_unchanged_in_content(self): + svg = '' + + result = strip_svg_hyperlinks(svg) + + self.assertIn("/elevation-svg/", + views.RackElevationOfflineSVGView.as_view(), + name="rack_elevation_offline_svg", + ), path("settings/", views.UtilitiesSettingsView.as_view(), name="settings"), path("tenant/select/", views.SelectTenantView.as_view(), name="select_tenant"), ] diff --git a/netbox_utilities/views.py b/netbox_utilities/views.py index 27c496d..5ab7aa0 100644 --- a/netbox_utilities/views.py +++ b/netbox_utilities/views.py @@ -1,10 +1,10 @@ -from dcim.models import Device, Module +from dcim.models import Device, Module, Rack from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.contenttypes.models import ContentType from django.core.exceptions import SuspiciousFileOperation, ValidationError from django.db import DatabaseError, transaction -from django.http import Http404, HttpResponseBadRequest, JsonResponse +from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.http import url_has_allowed_host_and_scheme @@ -33,8 +33,10 @@ from .navigation_helpers import ( from .runtime import ( clear_runtime_settings_cache, navigation_customization_enabled, + offline_svg_export_enabled, tenant_filter_enabled, ) +from .svg_export import strip_svg_hyperlinks def _safe_return_url(request, default_name="home"): @@ -364,3 +366,35 @@ class SelectTenantView(LoginRequiredMixin, View): return HttpResponseBadRequest("Ungültiger Mandantenfilter.") return redirect(_safe_return_url(request)) + + +class RackElevationOfflineSVGView(LoginRequiredMixin, View): + """Serve a rack elevation SVG stripped of hyperlinks, for offline use.""" + + def get(self, request, pk): + if not offline_svg_export_enabled(): + return HttpResponseBadRequest("Der Offline-SVG-Export ist deaktiviert.") + + rack = get_object_or_404(Rack.objects.restrict(request.user, "view"), pk=pk) + face = request.GET.get("face", "front") + if face not in ("front", "rear"): + return HttpResponseBadRequest("Ungültige Rack-Seite.") + + from dcim.api.views import RackViewSet + from rest_framework.test import APIRequestFactory + + factory = APIRequestFactory() + api_request = factory.get( + f"/api/dcim/racks/{rack.pk}/elevation/", + {"render": "svg", "face": face}, + ) + api_request.user = request.user + drf_view = RackViewSet.as_view({"get": "elevation"}) + response = drf_view(api_request, pk=rack.pk) + response.render() + + offline_svg = strip_svg_hyperlinks(response.content) + 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}"' + return http_response diff --git a/pyproject.toml b/pyproject.toml index 3ecb2a7..41de44b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "netbox-utilities" -version = "0.10.4" +version = "0.11.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"