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 <a>/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 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 14:22:04 +02:00
co-authored by Claude Sonnet 5
parent 35ca661ed0
commit 3768417be6
9 changed files with 186 additions and 6 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.10.4" __version__ = "0.11.0"
class NetBoxUtilitiesConfig(PluginConfig): class NetBoxUtilitiesConfig(PluginConfig):
@@ -19,6 +19,7 @@ class NetBoxUtilitiesConfig(PluginConfig):
"topology_views_rack_width_enabled": True, "topology_views_rack_width_enabled": True,
"tenant_filter_enabled": True, "tenant_filter_enabled": True,
"tenant_required": True, "tenant_required": True,
"offline_svg_export_enabled": True,
} }
middleware = [ middleware = [
"netbox_utilities.middleware.GlobalTenantFilterMiddleware", "netbox_utilities.middleware.GlobalTenantFilterMiddleware",
+4
View File
@@ -15,6 +15,10 @@ def connection_vlans_enabled():
return bool(get_plugin_config("netbox_utilities", "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(): def tenant_filter_enabled():
if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"): if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"):
return False return False
+48
View File
@@ -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 <a> 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)
+25 -2
View File
@@ -19,7 +19,12 @@ from .navigation_helpers import (
normalize_preferences, normalize_preferences,
normalize_sidebar_width, 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): 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,
]
@@ -0,0 +1,10 @@
<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">
<i class="mdi mdi-download" aria-hidden="true"></i>
SVG offline (vorne)
</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">
<i class="mdi mdi-download" aria-hidden="true"></i>
SVG offline (hinten)
</a>
+55
View File
@@ -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 = (
'<svg xmlns="http://www.w3.org/2000/svg">'
'<a xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="/dcim/devices/1/">'
'<rect width="10" height="10"/>'
"</a>"
"</svg>"
)
result = strip_svg_hyperlinks(svg)
self.assertNotIn("<a ", result)
self.assertNotIn("href", result)
self.assertIn("<rect", result)
def test_removes_href_on_non_anchor_elements(self):
svg = (
'<svg xmlns="http://www.w3.org/2000/svg" '
'xmlns:xlink="http://www.w3.org/1999/xlink">'
'<image xlink:href="/static/logo.png" href="/static/logo.png"/>'
"</svg>"
)
result = strip_svg_hyperlinks(svg)
self.assertNotIn("href", result)
self.assertIn("<image", result)
def test_preserves_sibling_order_around_unwrapped_anchor(self):
svg = (
'<svg xmlns="http://www.w3.org/2000/svg">'
'<rect id="before"/>'
'<a href="/dcim/devices/2/"><rect id="linked"/></a>'
'<rect id="after"/>'
"</svg>"
)
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 = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="5" height="5"/></svg>'
result = strip_svg_hyperlinks(svg)
self.assertIn("<rect", result)
self.assertNotIn("href", result)
+5
View File
@@ -9,6 +9,11 @@ urlpatterns = [
path("modules/bulk-install/", views.BulkModuleInstallView.as_view(), name="bulk_module_install"), path("modules/bulk-install/", views.BulkModuleInstallView.as_view(), name="bulk_module_install"),
path("navigation/", views.NavigationPreferencesView.as_view(), name="navigation_preferences"), path("navigation/", views.NavigationPreferencesView.as_view(), name="navigation_preferences"),
path("navigation/layout/", views.NavigationLayoutView.as_view(), name="navigation_layout"), path("navigation/layout/", views.NavigationLayoutView.as_view(), name="navigation_layout"),
path(
"racks/<int:pk>/elevation-svg/",
views.RackElevationOfflineSVGView.as_view(),
name="rack_elevation_offline_svg",
),
path("settings/", views.UtilitiesSettingsView.as_view(), name="settings"), path("settings/", views.UtilitiesSettingsView.as_view(), name="settings"),
path("tenant/select/", views.SelectTenantView.as_view(), name="select_tenant"), path("tenant/select/", views.SelectTenantView.as_view(), name="select_tenant"),
] ]
+36 -2
View File
@@ -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 import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import SuspiciousFileOperation, ValidationError from django.core.exceptions import SuspiciousFileOperation, ValidationError
from django.db import DatabaseError, transaction 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.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
@@ -33,8 +33,10 @@ from .navigation_helpers import (
from .runtime import ( from .runtime import (
clear_runtime_settings_cache, clear_runtime_settings_cache,
navigation_customization_enabled, navigation_customization_enabled,
offline_svg_export_enabled,
tenant_filter_enabled, tenant_filter_enabled,
) )
from .svg_export import strip_svg_hyperlinks
def _safe_return_url(request, default_name="home"): def _safe_return_url(request, default_name="home"):
@@ -364,3 +366,35 @@ class SelectTenantView(LoginRequiredMixin, View):
return HttpResponseBadRequest("Ungültiger Mandantenfilter.") return HttpResponseBadRequest("Ungültiger Mandantenfilter.")
return redirect(_safe_return_url(request)) 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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "netbox-utilities" 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" description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6/4.7"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"