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"