Add SVG/PNG/draw.io export for rack concepts, center the add-device hint

- Extract the elevation's <svg> body into inc/elevation_svg_content.html,
  shared by the on-page elevation and a new standalone export template
  so they never drift apart.
- Add "Export" dropdown above the elevation: Front/Rear as standalone
  SVG (self-contained inline styles, no dependency on the page's CSS,
  absolute device-type image URLs so the file still works once saved
  elsewhere), Front/Rear as PNG (rasterized entirely client-side via
  <canvas> from that same SVG - no new server-side image dependency),
  and the whole concept as a draw.io (.drawio / mxGraph XML) diagram
  with both faces side by side, reusing the identical block geometry.
- Center the "+ Add device" hint on empty units instead of
  right-aligning it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 12:34:26 +02:00
co-authored by Claude Sonnet 5
parent 27d915c45e
commit 2c991e37ae
9 changed files with 387 additions and 82 deletions
+44 -1
View File
@@ -6,18 +6,22 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Count
from django.http import HttpResponseForbidden, JsonResponse
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext as _
from django.views.generic import View
from dcim.choices import DeviceFaceChoices
from dcim.models import Rack
from netbox.views import generic
from utilities.views import ObjectPermissionRequiredMixin, ViewTab, register_model_view
from . import components, deploy, filtersets, forms, tables
from .choices import ConceptComponentTypeChoices
from .drawio import build_drawio
from .elevation import get_elevations
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
@@ -62,6 +66,45 @@ class RackConceptView(generic.ObjectView):
}
@register_model_view(RackConcept, 'export_svg', path='export/svg')
class RackConceptExportSVGView(ObjectPermissionRequiredMixin, View):
"""Download one face's elevation as a standalone SVG file."""
queryset = RackConcept.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.view_rackconcept'
def get(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
face = request.GET.get('face')
if face not in (DeviceFaceChoices.FACE_FRONT, DeviceFaceChoices.FACE_REAR):
face = DeviceFaceChoices.FACE_FRONT
front, rear = get_elevations(concept)
elevation = front if face == DeviceFaceChoices.FACE_FRONT else rear
svg = render_to_string('netbox_rack_concept/elevation_export.html', {'elevation': elevation}, request=request)
response = HttpResponse(svg, content_type='image/svg+xml')
response['Content-Disposition'] = f'attachment; filename="{slugify(concept.name)}-{face}.svg"'
return response
@register_model_view(RackConcept, 'export_drawio', path='export/drawio')
class RackConceptExportDrawioView(ObjectPermissionRequiredMixin, View):
"""Download both faces of a concept as one draw.io (diagrams.net) diagram."""
queryset = RackConcept.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.view_rackconcept'
def get(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
xml = build_drawio(concept)
response = HttpResponse(xml, content_type='application/xml')
response['Content-Disposition'] = f'attachment; filename="{slugify(concept.name)}.drawio"'
return response
@register_model_view(RackConcept, 'devices')
class RackConceptDevicesView(generic.ObjectChildrenView):
queryset = RackConcept.objects.all()