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"