Files
NetBox-Concept/netbox_rack_concept/views.py
T
MrBlakeandClaude Sonnet 5 67d999e234 v0.2.1: rework PNG export to draw directly on canvas, like netbox-topology-views
Investigated how MrBlake/mrb-netbox-topology-views does its PNG export:
it renders its topology natively onto a canvas (vis-network draws
there directly) and just calls canvas.toDataURL() - no SVG round-trip
at all. Applied the same principle here.

Previous approach loaded the standalone SVG export via <img src="blob:...">
and rasterized that onto a canvas - fragile, because the whole export
failed if that SVG document wasn't strictly valid XML (as our leaked
{# #} comment bug demonstrated) or if a single device type image
reference inside it couldn't be resolved.

New approach: a dedicated export/data/ endpoint returns the elevation's
plain pixel geometry as JSON (positions, sizes, colors, labels, image
URLs - the same numbers the SVG is built from), and export.js draws
that directly onto a <canvas> with native 2D primitives (fillRect,
strokeRect, fillText/strokeText, drawImage). Device type images are
fetched individually as same-origin blobs before drawing; one image
failing to load only drops that picture; the rest of the export still
succeeds. Added a combined front+rear PNG option.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:53:42 +02:00

866 lines
35 KiB
Python

import json
from decimal import Decimal, InvalidOperation
from django.contrib import messages
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 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
__all__ = (
'ConceptCableView',
'ConceptComponentView',
'ConceptDeviceView',
'MergeConceptsView',
'MergeRacksToConceptView',
'RackConceptView',
)
#
# Rack concepts
#
@register_model_view(RackConcept, 'list', path='', detail=False)
class RackConceptListView(generic.ObjectListView):
queryset = RackConcept.objects.annotate(
device_count=Count('devices')
).select_related('tenant', 'tenant_group', 'rack_type')
table = tables.RackConceptTable
filterset = filtersets.RackConceptFilterSet
filterset_form = forms.RackConceptFilterForm
@register_model_view(RackConcept)
class RackConceptView(generic.ObjectView):
queryset = RackConcept.objects.select_related('tenant', 'tenant_group', 'rack_type', 'source_rack')
template_name = 'netbox_rack_concept/rackconcept.html'
def get_extra_context(self, request, instance):
front, rear = get_elevations(instance)
deployments = tables.ConceptDeploymentTable(
instance.deployments.select_related('rack', 'user')
)
return {
'front_elevation': front,
'rear_elevation': rear,
'deployments_table': deployments,
}
@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_data', path='export/data')
class RackConceptExportDataView(ObjectPermissionRequiredMixin, View):
"""
The elevation's full pixel geometry as JSON, for the PNG export: it draws straight
onto a <canvas> from this rather than rasterizing an SVG image, so a device type
image only ever reaches the canvas via a same-origin fetch()->blob()->Image, never
through an <img src="blob:svg..."> whose validity (and therefore whether it even
decodes) depends on the SVG being perfectly well-formed.
"""
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)
front, rear = get_elevations(concept)
def serialize(elevation):
return {
'width': elevation.width,
'height': elevation.height,
'rack_x': elevation.rack_x,
'rack_width': elevation.rack_width,
'face_label': elevation.face_label.title(),
'units': [
{'number': unit.number, 'y': unit.y, 'height': unit.height}
for unit in elevation.units
],
'blocks': [
{
'x': block.x,
'y': block.y,
'width': block.width,
'height': block.height,
'color': block.color,
'text_color': block.text_color,
'label': block.label,
'opposite': block.opposite,
'show_text': block.show_text,
'image_url': (
request.build_absolute_uri(block.image.url) if block.image else None
),
}
for block in elevation.blocks
],
}
return JsonResponse({
'name': concept.name,
'front': serialize(front),
'rear': serialize(rear),
})
@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()
child_model = ConceptDevice
table = tables.ConceptDeviceElevationTable
filterset = filtersets.ConceptDeviceFilterSet
template_name = 'netbox_rack_concept/rackconcept_devices.html'
tab = ViewTab(
label=_('Devices'),
badge=lambda obj: obj.devices.count(),
permission='netbox_rack_concept.view_conceptdevice',
weight=500
)
def get_children(self, request, parent):
return parent.devices.select_related('device_type', 'device_type__manufacturer', 'role', 'platform', 'tenant')
@register_model_view(RackConcept, 'add', detail=False)
@register_model_view(RackConcept, 'edit')
class RackConceptEditView(generic.ObjectEditView):
queryset = RackConcept.objects.all()
form = forms.RackConceptForm
def alter_object(self, obj, request, url_args, url_kwargs):
if not obj.pk:
config = settings.PLUGINS_CONFIG.get('netbox_rack_concept', {})
obj.u_height = config.get('default_u_height', 42)
obj.width = config.get('default_width', 19)
return obj
@register_model_view(RackConcept, 'delete')
class RackConceptDeleteView(generic.ObjectDeleteView):
queryset = RackConcept.objects.all()
@register_model_view(RackConcept, 'bulk_import', path='import', detail=False)
class RackConceptBulkImportView(generic.BulkImportView):
queryset = RackConcept.objects.all()
model_form = forms.RackConceptImportForm
@register_model_view(RackConcept, 'bulk_edit', path='edit', detail=False)
class RackConceptBulkEditView(generic.BulkEditView):
queryset = RackConcept.objects.annotate(device_count=Count('devices'))
filterset = filtersets.RackConceptFilterSet
table = tables.RackConceptTable
form = forms.RackConceptBulkEditForm
@register_model_view(RackConcept, 'bulk_delete', path='delete', detail=False)
class RackConceptBulkDeleteView(generic.BulkDeleteView):
queryset = RackConcept.objects.annotate(device_count=Count('devices'))
filterset = filtersets.RackConceptFilterSet
table = tables.RackConceptTable
#
# Concept devices
#
@register_model_view(ConceptDevice, 'list', path='', detail=False)
class ConceptDeviceListView(generic.ObjectListView):
queryset = ConceptDevice.objects.select_related(
'concept', 'device_type', 'device_type__manufacturer', 'role', 'platform', 'tenant'
)
table = tables.ConceptDeviceTable
filterset = filtersets.ConceptDeviceFilterSet
filterset_form = forms.ConceptDeviceFilterForm
@register_model_view(ConceptDevice)
class ConceptDeviceView(generic.ObjectView):
queryset = ConceptDevice.objects.select_related(
'concept', 'device_type', 'device_type__manufacturer', 'role', 'platform', 'tenant', 'source_device'
)
template_name = 'netbox_rack_concept/conceptdevice.html'
@register_model_view(ConceptDevice, 'sync_components')
class ConceptDeviceSyncComponentsView(ObjectPermissionRequiredMixin, View):
"""Populate a device's components from its device type's templates."""
queryset = ConceptDevice.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.add_conceptcomponent'
def get(self, request, pk):
device = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
return render(request, 'netbox_rack_concept/conceptdevice_sync_components.html', {
'object': device,
'return_url': device.get_absolute_url(),
})
def post(self, request, pk):
device = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
if device.device_type is None:
messages.error(request, _('%(device)s has no device type to sync components from.') % {'device': device})
return redirect(device.get_absolute_url())
created, mapped = components.sync_components(device)
if created:
messages.success(request, _('Created %(count)s component(s).') % {'count': created})
else:
messages.info(request, _('No new components to create; everything is already in sync.'))
if mapped:
messages.success(request, _('Mapped %(count)s front/rear port pair(s).') % {'count': mapped})
return redirect(device.get_absolute_url())
class ConceptDeviceComponentsView(generic.ObjectChildrenView):
"""
Base for the per-type component tabs (Interfaces / Front Ports / ...), mirroring
dcim's own device component tabs one-for-one: same names, paths and tab order.
Since one ConceptComponent model covers every type (see models.py), a subclass only
needs to say which component_type it shows - there's no separate model per tab to
plug in the way dcim.Interface/dcim.FrontPort/etc. do.
"""
queryset = ConceptDevice.objects.all()
child_model = ConceptComponent
table = tables.ConceptComponentTable
filterset = filtersets.ConceptComponentFilterSet
filterset_form = forms.ConceptComponentFilterForm
template_name = 'netbox_rack_concept/conceptdevice_components.html'
component_type = None
def get_children(self, request, parent):
return ConceptComponent.objects.restrict(request.user, 'view').filter(
device=parent, component_type=self.component_type
)
def get_table(self, data, request, bulk_actions=True):
# device/concept/component_type are all implied by "which tab this is" - showing
# them again on every row would just be noise.
table = self.table(data, exclude=('device', 'concept', 'component_type'))
if 'pk' in table.base_columns and bulk_actions:
table.columns.show('pk')
table.configure(request)
return table
def get_extra_context(self, request, instance):
return {
'component_type': self.component_type,
'component_type_label': self.tab.label,
}
@register_model_view(ConceptDevice, 'consoleports', path='console-ports')
class ConceptDeviceConsolePortsView(ConceptDeviceComponentsView):
component_type = ConceptComponentTypeChoices.TYPE_CONSOLE_PORT
tab = ViewTab(
label=_('Console Ports'),
badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_CONSOLE_PORT).count(),
permission='netbox_rack_concept.view_conceptcomponent',
weight=550,
hide_if_empty=True
)
@register_model_view(ConceptDevice, 'consoleserverports', path='console-server-ports')
class ConceptDeviceConsoleServerPortsView(ConceptDeviceComponentsView):
component_type = ConceptComponentTypeChoices.TYPE_CONSOLE_SERVER_PORT
tab = ViewTab(
label=_('Console Server Ports'),
badge=lambda obj: obj.components.filter(
component_type=ConceptComponentTypeChoices.TYPE_CONSOLE_SERVER_PORT
).count(),
permission='netbox_rack_concept.view_conceptcomponent',
weight=560,
hide_if_empty=True
)
@register_model_view(ConceptDevice, 'powerports', path='power-ports')
class ConceptDevicePowerPortsView(ConceptDeviceComponentsView):
component_type = ConceptComponentTypeChoices.TYPE_POWER_PORT
tab = ViewTab(
label=_('Power Ports'),
badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_POWER_PORT).count(),
permission='netbox_rack_concept.view_conceptcomponent',
weight=570,
hide_if_empty=True
)
@register_model_view(ConceptDevice, 'poweroutlets', path='power-outlets')
class ConceptDevicePowerOutletsView(ConceptDeviceComponentsView):
component_type = ConceptComponentTypeChoices.TYPE_POWER_OUTLET
tab = ViewTab(
label=_('Power Outlets'),
badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_POWER_OUTLET).count(),
permission='netbox_rack_concept.view_conceptcomponent',
weight=580,
hide_if_empty=True
)
@register_model_view(ConceptDevice, 'interfaces')
class ConceptDeviceInterfacesView(ConceptDeviceComponentsView):
component_type = ConceptComponentTypeChoices.TYPE_INTERFACE
tab = ViewTab(
label=_('Interfaces'),
badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_INTERFACE).count(),
permission='netbox_rack_concept.view_conceptcomponent',
weight=520,
hide_if_empty=True
)
@register_model_view(ConceptDevice, 'frontports', path='front-ports')
class ConceptDeviceFrontPortsView(ConceptDeviceComponentsView):
component_type = ConceptComponentTypeChoices.TYPE_FRONT_PORT
tab = ViewTab(
label=_('Front Ports'),
badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_FRONT_PORT).count(),
permission='netbox_rack_concept.view_conceptcomponent',
weight=530,
hide_if_empty=True
)
@register_model_view(ConceptDevice, 'rearports', path='rear-ports')
class ConceptDeviceRearPortsView(ConceptDeviceComponentsView):
component_type = ConceptComponentTypeChoices.TYPE_REAR_PORT
tab = ViewTab(
label=_('Rear Ports'),
badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_REAR_PORT).count(),
permission='netbox_rack_concept.view_conceptcomponent',
weight=540,
hide_if_empty=True
)
@register_model_view(ConceptDevice, 'add', detail=False)
@register_model_view(ConceptDevice, 'edit')
class ConceptDeviceEditView(generic.ObjectEditView):
queryset = ConceptDevice.objects.all()
form = forms.ConceptDeviceForm
@register_model_view(ConceptDevice, 'delete')
class ConceptDeviceDeleteView(generic.ObjectDeleteView):
queryset = ConceptDevice.objects.all()
@register_model_view(ConceptDevice, 'bulk_import', path='import', detail=False)
class ConceptDeviceBulkImportView(generic.BulkImportView):
queryset = ConceptDevice.objects.all()
model_form = forms.ConceptDeviceImportForm
@register_model_view(ConceptDevice, 'bulk_edit', path='edit', detail=False)
class ConceptDeviceBulkEditView(generic.BulkEditView):
queryset = ConceptDevice.objects.select_related('concept', 'device_type', 'role')
filterset = filtersets.ConceptDeviceFilterSet
table = tables.ConceptDeviceTable
form = forms.ConceptDeviceBulkEditForm
@register_model_view(ConceptDevice, 'bulk_delete', path='delete', detail=False)
class ConceptDeviceBulkDeleteView(generic.BulkDeleteView):
queryset = ConceptDevice.objects.select_related('concept', 'device_type', 'role')
filterset = filtersets.ConceptDeviceFilterSet
table = tables.ConceptDeviceTable
#
# Actions: copy, clone, deploy, reorder
#
class RackCopyView(ObjectPermissionRequiredMixin, View):
"""Copy a real rack into a new concept. Reached from the rack detail page."""
queryset = RackConcept.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.add_rackconcept'
def get(self, request, pk):
rack = get_object_or_404(Rack.objects.restrict(request.user, 'view'), pk=pk)
form = forms.RackCopyForm(initial={
'name': _('%(rack)s (concept)') % {'rack': rack.name},
'tenant': rack.tenant_id,
'tenant_group': rack.tenant.group_id if rack.tenant else None,
})
return render(request, 'netbox_rack_concept/rack_copy.html', {
'rack': rack,
'form': form,
'device_count': rack.devices.filter(position__isnull=False).count(),
'return_url': rack.get_absolute_url(),
})
def post(self, request, pk):
rack = get_object_or_404(Rack.objects.restrict(request.user, 'view'), pk=pk)
form = forms.RackCopyForm(request.POST)
if form.is_valid():
try:
concept, warnings, cable_summary = deploy.copy_rack_to_concept(
rack=rack,
name=form.cleaned_data['name'],
status=form.cleaned_data['status'],
tenant=form.cleaned_data['tenant'],
tenant_group=form.cleaned_data['tenant_group'],
description=form.cleaned_data['description'],
include_devices=form.cleaned_data['include_devices'],
include_device_names=form.cleaned_data['include_device_names'],
override_width=form.cleaned_data['override_width'],
override_u_height=form.cleaned_data['override_u_height'],
override_starting_unit=form.cleaned_data['override_starting_unit'],
)
except ValidationError as exc:
form.add_error(None, exc)
else:
for warning in warnings:
messages.warning(request, warning)
if cable_summary['skipped']:
messages.warning(request, _(
'%(count)s cable(s) could not be copied because they reach outside the rack or use more '
'than one termination per side.'
) % {'count': cable_summary['skipped']})
success_message = _('Created concept %(concept)s from rack %(rack)s.') % {
'concept': concept, 'rack': rack
}
if cable_summary['copied']:
success_message += ' ' + _('Copied %(count)s cable(s).') % {'count': cable_summary['copied']}
messages.success(request, success_message)
return redirect(concept.get_absolute_url())
return render(request, 'netbox_rack_concept/rack_copy.html', {
'rack': rack,
'form': form,
'device_count': rack.devices.filter(position__isnull=False).count(),
'return_url': rack.get_absolute_url(),
})
class MergeConceptsView(ObjectPermissionRequiredMixin, View):
"""Stack two blueprints (e.g. two half-height racks) into one taller concept."""
queryset = RackConcept.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.add_rackconcept'
def get(self, request):
form = forms.MergeConceptsForm(initial={
'concept_bottom': request.GET.get('concept_bottom'),
'concept_top': request.GET.get('concept_top'),
})
return render(request, 'netbox_rack_concept/concept_merge.html', {
'form': form,
'return_url': reverse('plugins:netbox_rack_concept:rackconcept_list'),
})
def post(self, request):
form = forms.MergeConceptsForm(request.POST)
if form.is_valid():
try:
merged, warnings = deploy.merge_concepts(
concept_bottom=form.cleaned_data['concept_bottom'],
concept_top=form.cleaned_data['concept_top'],
name=form.cleaned_data['name'],
status=form.cleaned_data['status'],
tenant=form.cleaned_data['tenant'],
tenant_group=form.cleaned_data['tenant_group'],
width=form.cleaned_data['width'],
u_height=form.cleaned_data['u_height'],
starting_unit=form.cleaned_data['starting_unit'],
description=form.cleaned_data['description'],
)
except ValidationError as exc:
form.add_error(None, exc)
else:
for warning in warnings:
messages.warning(request, warning)
messages.success(request, _('Created %(merged)s by merging %(bottom)s and %(top)s.') % {
'merged': merged,
'bottom': form.cleaned_data['concept_bottom'],
'top': form.cleaned_data['concept_top'],
})
return redirect(merged.get_absolute_url())
return render(request, 'netbox_rack_concept/concept_merge.html', {
'form': form,
'return_url': reverse('plugins:netbox_rack_concept:rackconcept_list'),
})
class MergeRacksToConceptView(ObjectPermissionRequiredMixin, View):
"""Combine two real racks directly into one new, stacked blueprint."""
queryset = RackConcept.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.add_rackconcept'
def get(self, request):
form = forms.MergeRacksForm(initial={
'rack_bottom': request.GET.get('rack_bottom'),
'rack_top': request.GET.get('rack_top'),
})
return render(request, 'netbox_rack_concept/racks_merge.html', {
'form': form,
'return_url': reverse('dcim:rack_list'),
})
def post(self, request):
form = forms.MergeRacksForm(request.POST)
if form.is_valid():
try:
concept, warnings, cable_summary = deploy.merge_racks_to_concept(
rack_bottom=form.cleaned_data['rack_bottom'],
rack_top=form.cleaned_data['rack_top'],
name=form.cleaned_data['name'],
status=form.cleaned_data['status'],
tenant=form.cleaned_data['tenant'],
tenant_group=form.cleaned_data['tenant_group'],
include_devices=form.cleaned_data['include_devices'],
include_device_names=form.cleaned_data['include_device_names'],
width=form.cleaned_data['width'],
u_height=form.cleaned_data['u_height'],
starting_unit=form.cleaned_data['starting_unit'],
description=form.cleaned_data['description'],
)
except ValidationError as exc:
form.add_error(None, exc)
else:
for warning in warnings:
messages.warning(request, warning)
if cable_summary['skipped']:
messages.warning(request, _(
'%(count)s cable(s) could not be copied because they reach outside the two racks or use '
'more than one termination per side.'
) % {'count': cable_summary['skipped']})
success_message = _('Created concept %(concept)s from %(bottom)s and %(top)s.') % {
'concept': concept,
'bottom': form.cleaned_data['rack_bottom'],
'top': form.cleaned_data['rack_top'],
}
if cable_summary['copied']:
success_message += ' ' + _('Copied %(count)s cable(s).') % {'count': cable_summary['copied']}
messages.success(request, success_message)
return redirect(concept.get_absolute_url())
return render(request, 'netbox_rack_concept/racks_merge.html', {
'form': form,
'return_url': reverse('dcim:rack_list'),
})
@register_model_view(RackConcept, 'clone_concept', path='clone')
class RackConceptCloneView(ObjectPermissionRequiredMixin, View):
"""Duplicate a concept to build a variant."""
queryset = RackConcept.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.add_rackconcept'
def get(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
form = forms.RackConceptCloneForm(initial={
'name': _('%(name)s (copy)') % {'name': concept.name},
'tenant': concept.tenant_id,
})
return render(request, 'netbox_rack_concept/concept_clone.html', {
'object': concept,
'form': form,
'return_url': concept.get_absolute_url(),
})
def post(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
form = forms.RackConceptCloneForm(request.POST)
if form.is_valid():
try:
clone = deploy.clone_concept(
concept=concept,
name=form.cleaned_data['name'],
status=form.cleaned_data['status'],
tenant=form.cleaned_data['tenant'],
include_devices=form.cleaned_data['include_devices'],
)
except ValidationError as exc:
form.add_error(None, exc)
else:
messages.success(request, _('Cloned %(source)s into %(clone)s.') % {
'source': concept, 'clone': clone
})
return redirect(clone.get_absolute_url())
return render(request, 'netbox_rack_concept/concept_clone.html', {
'object': concept,
'form': form,
'return_url': concept.get_absolute_url(),
})
@register_model_view(RackConcept, 'deploy')
class RackConceptDeployView(ObjectPermissionRequiredMixin, View):
"""Materialise a concept as one or more real racks."""
queryset = RackConcept.objects.all()
def get_required_permission(self):
# The mixin restricts against this view's queryset, so it has to be a concept
# permission. The permission to create racks is checked separately below.
return 'netbox_rack_concept.view_rackconcept'
def _context(self, request, concept, form):
# An unchecked checkbox is simply absent from the POST data.
create_devices = 'create_devices' in form.data if form.is_bound else True
deployable, warnings = deploy.validate_deployment(concept, create_devices=create_devices)
return {
'object': concept,
'form': form,
'deployable_count': len(deployable),
'warnings': warnings,
'return_url': concept.get_absolute_url(),
}
def get(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
form = forms.ConceptDeployForm(concept=concept)
return render(request, 'netbox_rack_concept/concept_deploy.html', self._context(request, concept, form))
def post(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
form = forms.ConceptDeployForm(request.POST, concept=concept)
if not request.user.has_perm('dcim.add_rack'):
return HttpResponseForbidden(_('You do not have permission to create racks.'))
if form.is_valid():
try:
racks, devices, warnings = deploy.deploy_concept(
concept=concept,
rack_names=form.cleaned_data['rack_names'],
site=form.cleaned_data['site'],
location=form.cleaned_data['location'],
tenant=form.cleaned_data['tenant'],
create_devices=form.cleaned_data['create_devices'],
device_name_pattern=form.cleaned_data['device_name_pattern'],
skip_unnamed_devices=form.cleaned_data['skip_unnamed_devices'],
user=request.user,
)
except ValidationError as exc:
# Nothing was written: deploy_concept runs in a transaction.
messages.error(request, _('Deployment failed, nothing was created.'))
form.add_error(None, exc)
else:
for warning in warnings:
messages.warning(request, warning)
messages.success(request, _('Created %(racks)s rack(s) and %(devices)s device(s).') % {
'racks': len(racks), 'devices': len(devices)
})
if len(racks) == 1:
return redirect(racks[0].get_absolute_url())
return redirect(
reverse('dcim:rack_list') + f'?site_id={form.cleaned_data["site"].pk}'
)
return render(request, 'netbox_rack_concept/concept_deploy.html', self._context(request, concept, form))
@register_model_view(RackConcept, 'reorder')
class RackConceptReorderView(ObjectPermissionRequiredMixin, View):
"""
Endpoint for the drag & drop elevation. Accepts a JSON body of the shape
{"devices": [{"id": 1, "position": "12.0", "face": "front", "horizontal_position": 0}]}
and applies all moves in one transaction so a rejected move leaves nothing half-applied.
"""
queryset = RackConcept.objects.all()
http_method_names = ['post']
def get_required_permission(self):
return 'netbox_rack_concept.change_conceptdevice'
def post(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
try:
payload = json.loads(request.body or '{}')
moves = payload['devices']
except (json.JSONDecodeError, KeyError, TypeError):
return JsonResponse({'error': _('Malformed request body.')}, status=400)
by_pk = {d.pk: d for d in concept.devices.select_related('device_type')}
updated = []
try:
with transaction.atomic():
for move in moves:
device = by_pk.get(int(move['id']))
if device is None:
raise ValidationError(_('Device %(id)s does not belong to this concept.') % {'id': move['id']})
try:
device.position = Decimal(str(move['position'])) if move.get('position') is not None else None
except InvalidOperation:
raise ValidationError(_('Invalid position %(position)r.') % {'position': move.get('position')})
if 'face' in move:
device.face = move['face']
if 'horizontal_position' in move:
device.horizontal_position = int(move['horizontal_position'])
if 'width_fraction' in move:
device.width_fraction = int(move['width_fraction'])
updated.append(device)
# Validate only after every move is staged, so swapping two devices works.
for device in updated:
device.save()
for device in updated:
device.full_clean()
except ValidationError as exc:
return JsonResponse({'error': ' '.join(exc.messages)}, status=400)
return JsonResponse({'status': 'ok', 'updated': len(updated)})
#
# Concept components
#
@register_model_view(ConceptComponent, 'list', path='', detail=False)
class ConceptComponentListView(generic.ObjectListView):
queryset = ConceptComponent.objects.select_related('device', 'device__concept')
table = tables.ConceptComponentTable
filterset = filtersets.ConceptComponentFilterSet
filterset_form = forms.ConceptComponentFilterForm
@register_model_view(ConceptComponent)
class ConceptComponentView(generic.ObjectView):
queryset = ConceptComponent.objects.select_related('device', 'device__concept')
template_name = 'netbox_rack_concept/conceptcomponent.html'
def get_extra_context(self, request, instance):
return {
'paired_components': instance.get_paired_components(),
'cable': instance.cable,
}
@register_model_view(ConceptComponent, 'add', detail=False)
@register_model_view(ConceptComponent, 'edit')
class ConceptComponentEditView(generic.ObjectEditView):
queryset = ConceptComponent.objects.all()
form = forms.ConceptComponentForm
@register_model_view(ConceptComponent, 'delete')
class ConceptComponentDeleteView(generic.ObjectDeleteView):
queryset = ConceptComponent.objects.all()
@register_model_view(ConceptComponent, 'bulk_delete', path='delete', detail=False)
class ConceptComponentBulkDeleteView(generic.BulkDeleteView):
queryset = ConceptComponent.objects.select_related('device')
filterset = filtersets.ConceptComponentFilterSet
table = tables.ConceptComponentTable
#
# Concept cables
#
@register_model_view(ConceptCable, 'list', path='', detail=False)
class ConceptCableListView(generic.ObjectListView):
queryset = ConceptCable.objects.select_related(
'termination_a', 'termination_a__device', 'termination_b', 'termination_b__device'
)
table = tables.ConceptCableTable
filterset = filtersets.ConceptCableFilterSet
filterset_form = forms.ConceptCableFilterForm
@register_model_view(ConceptCable)
class ConceptCableView(generic.ObjectView):
queryset = ConceptCable.objects.select_related(
'termination_a', 'termination_a__device', 'termination_b', 'termination_b__device'
)
template_name = 'netbox_rack_concept/conceptcable.html'
@register_model_view(ConceptCable, 'add', detail=False)
@register_model_view(ConceptCable, 'edit')
class ConceptCableEditView(generic.ObjectEditView):
queryset = ConceptCable.objects.all()
form = forms.ConceptCableForm
@register_model_view(ConceptCable, 'delete')
class ConceptCableDeleteView(generic.ObjectDeleteView):
queryset = ConceptCable.objects.all()
@register_model_view(ConceptCable, 'bulk_delete', path='delete', detail=False)
class ConceptCableBulkDeleteView(generic.BulkDeleteView):
queryset = ConceptCable.objects.select_related('termination_a', 'termination_b')
filterset = filtersets.ConceptCableFilterSet
table = tables.ConceptCableTable