Files
NetBox-Concept/netbox_rack_concept/views.py
T
MrBlakeandClaude Sonnet 5 e4817ea83b Add images/labels toggle, clean up label styling, merge real racks
- Replace the device-image label background bar and text-shadow with
  NetBox's own technique: a black-stroke pass behind a white-fill pass
  of the same text, giving crisp readable labels over any image
  without a dark box behind them.
- Add an "Images and labels / Images only / Labels only" selector
  above the elevation, mirroring the equivalent control on real NetBox
  rack elevations. The plain label is always in the markup; the
  selector only toggles the image and its over-image label, exactly
  like core NetBox does it. Choice persists per browser via
  localStorage and works without edit permissions.
- Add a "Merge into concept" button to the real dcim Rack list: select
  two racks via the existing bulk-select checkboxes and it forwards
  them to the concept-merge page, pre-filled. New
  deploy.merge_racks_to_concept() combines two real racks' devices
  directly into one new, stacked blueprint without an intermediate
  per-rack copy step.

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

509 lines
20 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 HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.generic import View
from dcim.models import Rack
from netbox.views import generic
from utilities.views import ObjectPermissionRequiredMixin, ViewTab, register_model_view
from . import deploy, filtersets, forms, tables
from .elevation import get_elevations
from .models import ConceptDevice, RackConcept
__all__ = (
'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, '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, '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 = 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)
messages.success(request, _('Created concept %(concept)s from rack %(rack)s.') % {
'concept': concept, 'rack': rack
})
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 = 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)
messages.success(request, _('Created concept %(concept)s from %(bottom)s and %(top)s.') % {
'concept': concept,
'bottom': form.cleaned_data['rack_bottom'],
'top': form.cleaned_data['rack_top'],
})
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)})