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>
This commit is contained in:
2026-09-16 09:50:23 +02:00
co-authored by Claude Sonnet 5
parent 777ded3900
commit e4817ea83b
13 changed files with 430 additions and 39 deletions
+80
View File
@@ -22,6 +22,7 @@ __all__ = (
'ConceptDeviceForm',
'ConceptDeviceImportForm',
'MergeConceptsForm',
'MergeRacksForm',
'RackConceptBulkEditForm',
'RackConceptCloneForm',
'RackConceptFilterForm',
@@ -422,6 +423,85 @@ class MergeConceptsForm(forms.Form):
return cleaned
class MergeRacksForm(forms.Form):
"""Combine two existing racks directly into one new, stacked blueprint."""
name = forms.CharField(
label=_('Concept name'),
max_length=100
)
status = forms.ChoiceField(
choices=ConceptStatusChoices,
initial=ConceptStatusChoices.STATUS_DRAFT
)
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_('Tenant group')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={'group_id': '$tenant_group'},
label=_('Tenant')
)
rack_bottom = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label=_('Bottom rack'),
help_text=_('Keeps its planned positions unchanged')
)
rack_top = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label=_('Top rack'),
help_text=_("Its devices are shifted up by the bottom rack's height")
)
include_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Copy installed devices')
)
include_device_names = forms.BooleanField(
required=False,
initial=True,
label=_('Keep device names')
)
width = forms.ChoiceField(
choices=RackWidthChoices,
initial=RackWidthChoices.WIDTH_19IN,
label=_('Width of the combined rack')
)
u_height = forms.IntegerField(
required=False,
min_value=1,
max_value=100,
label=_('Height (U)'),
help_text=_('Leave empty to use the sum of both heights')
)
starting_unit = forms.IntegerField(
initial=1,
min_value=1,
label=_('Starting unit')
)
description = forms.CharField(max_length=200, required=False)
fieldsets = (
FieldSet('name', 'status', 'description', name=_('Concept')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet('rack_bottom', 'rack_top', name=_('Sources')),
FieldSet('include_devices', 'include_device_names', name=_('Content')),
FieldSet('width', 'u_height', 'starting_unit', name=_('Combined size')),
)
def clean_width(self):
return int(self.cleaned_data['width'])
def clean(self):
cleaned = super().clean()
bottom, top = cleaned.get('rack_bottom'), cleaned.get('rack_top')
if bottom and top and bottom.pk == top.pk:
raise forms.ValidationError(_('Choose two different racks to merge.'))
return cleaned
class RackConceptCloneForm(forms.Form):
"""Duplicate a blueprint, for building variants."""
name = forms.CharField(