Fix elevation overlap, add device images, rack merge, and size override

- Fix elevation rendering bug: device blocks were drawn MARGIN px
  further right than the rack frame/unit slots, causing them to
  overlap and extend past the right border. All three now share one
  RACK_X origin.
- Show the device type's front/rear image inside each elevation block
  (clipped to the block, translucent label bar on top for legibility)
  when one is set on the device type; falls back to a plain colour
  block with text otherwise.
- Add "Merge" action to combine two concepts into one taller one (e.g.
  two half-height racks stacked into a full-height rack): the top
  concept's devices are shifted up by the bottom concept's height,
  neither source is modified. Available from the plugin menu, from a
  concept's detail page, and via POST /concepts/merge/.
- Copying a real rack into a concept can now target a different width,
  height and/or starting unit than the source rack; devices that no
  longer fit at the chosen size are skipped and reported as warnings
  instead of aborting the copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 09:35:18 +02:00
co-authored by Claude Sonnet 5
parent a705b64c22
commit 777ded3900
13 changed files with 432 additions and 22 deletions
+7 -2
View File
@@ -12,8 +12,9 @@ Wenn die Planung steht, wird das Konzept per Knopfdruck in echte NetBox-Objekte
| Weg | Wo | | Weg | Wo |
|---|---| |---|---|
| Echtes Rack → Konzept | Button **„Copy to concept"** auf der Rack-Detailseite | | Echtes Rack → Konzept | Button **„Copy to concept"** auf der Rack-Detailseite. Größe des Konzept-Racks (Breite, Höhe, Startunit) ist dabei frei wählbar; Geräte, die in der gewählten Größe keinen Platz mehr finden, werden ausgelassen und als Warnung gemeldet |
| Konzept → Konzept (Variante) | Button **„Clone concept"** auf der Konzept-Detailseite | | Konzept → Konzept (Variante) | Button **„Clone concept"** auf der Konzept-Detailseite |
| 2 Konzepte → 1 größeres Konzept | Button **„Merge"** im Menü bzw. **„Merge with another"** auf der Konzept-Detailseite — stapelt z.B. zwei halbhohe Racks zu einem vollen; das obere Konzept wird um die Höhe des unteren nach oben verschoben, beide Quellen bleiben unverändert |
| Konzept → echte Racks (n-fach) | Button **„Deploy to NetBox"**, mit Namensschema `DC1-R{n:02d}` | | Konzept → echte Racks (n-fach) | Button **„Deploy to NetBox"**, mit Namensschema `DC1-R{n:02d}` |
| Konzept von Hand anlegen | Menü **Rack Concepts → Rack Concepts → +** | | Konzept von Hand anlegen | Menü **Rack Concepts → Rack Concepts → +** |
@@ -21,6 +22,9 @@ Dazu:
* **Elevation mit Drag & Drop** – Geräte per Maus verschieben, serverseitig validiert. * **Elevation mit Drag & Drop** – Geräte per Maus verschieben, serverseitig validiert.
Ein ungültiger Zug wird abgelehnt und ändert nichts. Ein ungültiger Zug wird abgelehnt und ändert nichts.
* **Geräte mit Bild und Text** – ist am Gerätetyp ein Front-/Rear-Bild hinterlegt, wird
es im Elevation-Block angezeigt, mit halbtransparentem Label darüber; ohne Bild bleibt
es bei der reinen Farbfläche mit Text.
* **Teilbreiten** (1/1, 1/2, 1/3, 1/4 plus horizontale Slot-Position), passend zum * **Teilbreiten** (1/1, 1/2, 1/3, 1/4 plus horizontale Slot-Position), passend zum
Teilbreiten-Feature von `netbox_utilities`. Teilbreiten-Feature von `netbox_utilities`.
* **Mandant und Mandantengruppe** an Konzept und an jedem geplanten Gerät. * **Mandant und Mandantengruppe** an Konzept und an jedem geplanten Gerät.
@@ -159,8 +163,9 @@ Basis-Pfad: `/api/plugins/rack-concepts/`
|---|---|---| |---|---|---|
| `GET/POST` | `concepts/` | Konzepte auflisten und anlegen | | `GET/POST` | `concepts/` | Konzepte auflisten und anlegen |
| `GET/PATCH/DELETE` | `concepts/{id}/` | Einzelnes Konzept | | `GET/PATCH/DELETE` | `concepts/{id}/` | Einzelnes Konzept |
| `POST` | `concepts/copy-from-rack/` | Echtes Rack in ein Konzept kopieren | | `POST` | `concepts/copy-from-rack/` | Echtes Rack in ein Konzept kopieren (mit `override_width`/`override_u_height`/`override_starting_unit`) |
| `POST` | `concepts/{id}/clone/` | Konzept duplizieren | | `POST` | `concepts/{id}/clone/` | Konzept duplizieren |
| `POST` | `concepts/merge/` | Zwei Konzepte zu einem größeren stapeln (`concept_bottom`, `concept_top`) |
| `POST` | `concepts/{id}/deploy/` | Konzept ausrollen (`dry_run` möglich) | | `POST` | `concepts/{id}/deploy/` | Konzept ausrollen (`dry_run` möglich) |
| `GET` | `concepts/{id}/elevation/` | Berechnete Elevation als JSON | | `GET` | `concepts/{id}/elevation/` | Berechnete Elevation als JSON |
| `GET/POST` | `devices/` | Geplante Geräte | | `GET/POST` | `devices/` | Geplante Geräte |
+22
View File
@@ -4,6 +4,7 @@ from dcim.api.serializers import (
DeviceRoleSerializer, DeviceTypeSerializer, LocationSerializer, PlatformSerializer, RackRoleSerializer, DeviceRoleSerializer, DeviceTypeSerializer, LocationSerializer, PlatformSerializer, RackRoleSerializer,
RackSerializer, RackTypeSerializer, SiteSerializer, RackSerializer, RackTypeSerializer, SiteSerializer,
) )
from dcim.choices import RackWidthChoices
from dcim.models import Location, Rack, Site from dcim.models import Location, Rack, Site
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.serializers import TenantGroupSerializer, TenantSerializer from tenancy.api.serializers import TenantGroupSerializer, TenantSerializer
@@ -93,6 +94,9 @@ class RackCopySerializer(serializers.Serializer):
description = serializers.CharField(max_length=200, required=False, allow_blank=True, default='') description = serializers.CharField(max_length=200, required=False, allow_blank=True, default='')
include_devices = serializers.BooleanField(default=True) include_devices = serializers.BooleanField(default=True)
include_device_names = serializers.BooleanField(default=True) include_device_names = serializers.BooleanField(default=True)
override_width = serializers.ChoiceField(choices=RackWidthChoices, required=False, allow_null=True, default=None)
override_u_height = serializers.IntegerField(min_value=1, max_value=100, required=False, allow_null=True, default=None)
override_starting_unit = serializers.IntegerField(min_value=1, required=False, allow_null=True, default=None)
class RackConceptCloneSerializer(serializers.Serializer): class RackConceptCloneSerializer(serializers.Serializer):
@@ -103,6 +107,24 @@ class RackConceptCloneSerializer(serializers.Serializer):
include_devices = serializers.BooleanField(default=True) include_devices = serializers.BooleanField(default=True)
class MergeConceptsSerializer(serializers.Serializer):
"""POST body for stacking two concepts into one taller one."""
concept_bottom = serializers.PrimaryKeyRelatedField(queryset=RackConcept.objects.all())
concept_top = serializers.PrimaryKeyRelatedField(queryset=RackConcept.objects.all())
name = serializers.CharField(max_length=100)
status = serializers.ChoiceField(choices=ConceptStatusChoices, default=ConceptStatusChoices.STATUS_DRAFT)
tenant = serializers.PrimaryKeyRelatedField(queryset=Tenant.objects.all(), required=False, allow_null=True)
width = serializers.ChoiceField(choices=RackWidthChoices, required=False, allow_null=True, default=None)
u_height = serializers.IntegerField(min_value=1, max_value=100, required=False, allow_null=True, default=None)
starting_unit = serializers.IntegerField(min_value=1, default=1)
description = serializers.CharField(max_length=200, required=False, allow_blank=True, default='')
def validate(self, attrs):
if attrs['concept_bottom'].pk == attrs['concept_top'].pk:
raise serializers.ValidationError('concept_bottom and concept_top must be different concepts.')
return attrs
class ConceptDeploySerializer(serializers.Serializer): class ConceptDeploySerializer(serializers.Serializer):
"""POST body for materialising a concept.""" """POST body for materialising a concept."""
name_pattern = serializers.CharField(max_length=100) name_pattern = serializers.CharField(max_length=100)
+38 -7
View File
@@ -13,8 +13,8 @@ from .. import deploy as deploy_utils
from ..elevation import get_elevations from ..elevation import get_elevations
from ..models import ConceptDevice, RackConcept from ..models import ConceptDevice, RackConcept
from .serializers import ( from .serializers import (
ConceptDeploySerializer, ConceptDeviceSerializer, RackConceptCloneSerializer, RackConceptSerializer, ConceptDeploySerializer, ConceptDeviceSerializer, MergeConceptsSerializer, RackConceptCloneSerializer,
RackCopySerializer, RackConceptSerializer, RackCopySerializer,
) )
__all__ = ( __all__ = (
@@ -41,7 +41,7 @@ class RackConceptViewSet(NetBoxModelViewSet):
tenant = data.get('tenant') tenant = data.get('tenant')
try: try:
concept = deploy_utils.copy_rack_to_concept( concept, warnings = deploy_utils.copy_rack_to_concept(
rack=rack, rack=rack,
name=data['name'], name=data['name'],
status=data['status'], status=data['status'],
@@ -50,15 +50,17 @@ class RackConceptViewSet(NetBoxModelViewSet):
description=data['description'], description=data['description'],
include_devices=data['include_devices'], include_devices=data['include_devices'],
include_device_names=data['include_device_names'], include_device_names=data['include_device_names'],
override_width=data.get('override_width'),
override_u_height=data.get('override_u_height'),
override_starting_unit=data.get('override_starting_unit'),
) )
except ValidationError as exc: except ValidationError as exc:
return Response({'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages}, return Response({'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages},
status=status.HTTP_400_BAD_REQUEST) status=status.HTTP_400_BAD_REQUEST)
return Response( result = RackConceptSerializer(concept, context={'request': request}).data
RackConceptSerializer(concept, context={'request': request}).data, result['warnings'] = warnings
status=status.HTTP_201_CREATED return Response(result, status=status.HTTP_201_CREATED)
)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def clone(self, request, pk=None): def clone(self, request, pk=None):
@@ -85,6 +87,35 @@ class RackConceptViewSet(NetBoxModelViewSet):
status=status.HTTP_201_CREATED status=status.HTTP_201_CREATED
) )
@action(detail=False, methods=['post'])
def merge(self, request):
"""Stack two concepts (e.g. two half-height racks) into one taller one."""
serializer = MergeConceptsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
tenant = data.get('tenant')
try:
merged, warnings = deploy_utils.merge_concepts(
concept_bottom=data['concept_bottom'],
concept_top=data['concept_top'],
name=data['name'],
status=data['status'],
tenant=tenant,
tenant_group=tenant.group if tenant else None,
width=data.get('width'),
u_height=data.get('u_height'),
starting_unit=data['starting_unit'],
description=data['description'],
)
except ValidationError as exc:
return Response({'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages},
status=status.HTTP_400_BAD_REQUEST)
result = RackConceptSerializer(merged, context={'request': request}).data
result['warnings'] = warnings
return Response(result, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def deploy(self, request, pk=None): def deploy(self, request, pk=None):
""" """
+112 -5
View File
@@ -1,7 +1,9 @@
""" """
The three copy paths: rack -> concept, concept -> concept, concept -> rack(s). The four copy paths: rack -> concept, concept -> concept, concept -> rack(s), and
concept + concept -> one merged concept.
""" """
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@@ -15,6 +17,7 @@ __all__ = (
'clone_concept', 'clone_concept',
'copy_rack_to_concept', 'copy_rack_to_concept',
'deploy_concept', 'deploy_concept',
'merge_concepts',
'validate_deployment', 'validate_deployment',
) )
@@ -34,8 +37,15 @@ def _config():
@transaction.atomic @transaction.atomic
def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, tenant_group=None, def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, tenant_group=None,
description='', include_devices=True, include_device_names=True): description='', include_devices=True, include_device_names=True,
"""Snapshot a real rack, and optionally its contents, into a new blueprint.""" override_width=None, override_u_height=None, override_starting_unit=None):
"""
Snapshot a real rack, and optionally its contents, into a new blueprint. Passing an
override lets the concept end up a different size than the source rack (e.g. to
plan "what if we had a taller one"); devices that no longer fit are skipped and
reported back as warnings rather than aborting the whole copy.
Returns (concept, warnings).
"""
concept = RackConcept( concept = RackConcept(
name=name, name=name,
status=status, status=status,
@@ -55,16 +65,31 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t
value = getattr(rack.rack_type, field, None) value = getattr(rack.rack_type, field, None)
if value not in (None, ''): if value not in (None, ''):
setattr(concept, field, value) setattr(concept, field, value)
has_override = any(v is not None for v in (override_width, override_u_height, override_starting_unit))
if has_override:
# The concept's own geometry fields are ignored on deploy as long as rack_type
# is set (NetBox derives geometry from the type). Since the override is the
# whole point here, it must not be silently overruled by the source's type.
concept.rack_type = None
if override_width:
concept.width = override_width
if override_u_height:
concept.u_height = override_u_height
if override_starting_unit:
concept.starting_unit = override_starting_unit
concept.full_clean() concept.full_clean()
concept.save() concept.save()
warnings = []
if include_devices: if include_devices:
devices = Device.objects.filter(rack=rack, position__isnull=False).select_related( devices = Device.objects.filter(rack=rack, position__isnull=False).select_related(
'device_type', 'role', 'platform', 'tenant' 'device_type', 'role', 'platform', 'tenant'
) )
for device in devices: for device in devices:
fraction, horizontal = read_partial_width(device) fraction, horizontal = read_partial_width(device)
ConceptDevice.objects.create( concept_device = ConceptDevice(
concept=concept, concept=concept,
name=(device.name or '') if include_device_names else '', name=(device.name or '') if include_device_names else '',
status=ConceptDeviceStatusChoices.STATUS_EXISTING, status=ConceptDeviceStatusChoices.STATUS_EXISTING,
@@ -79,8 +104,90 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t
serial=device.serial if include_device_names else '', serial=device.serial if include_device_names else '',
source_device=device, source_device=device,
) )
if has_override:
try:
concept_device.full_clean()
except ValidationError:
warnings.append(_(
'%(device)s (U%(position)s) does not fit in the chosen size and was left out.'
) % {'device': device, 'position': device.position})
continue
concept_device.save()
return concept return concept, warnings
#
# RackConcept + RackConcept -> RackConcept (stacked)
#
@transaction.atomic
def merge_concepts(concept_bottom, concept_top, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None,
tenant_group=None, width=None, u_height=None, starting_unit=1, description=''):
"""
Stack two blueprints into one taller one - e.g. two half-height racks combined into
a full-height rack. concept_bottom's devices keep their position; concept_top's are
shifted up by concept_bottom's height. Neither source concept is modified.
Returns (merged_concept, warnings).
"""
offset = concept_bottom.u_height
total_height = u_height or (concept_bottom.u_height + concept_top.u_height)
merged = RackConcept(
name=name,
status=status,
tenant=tenant,
tenant_group=tenant_group,
description=description,
width=width or concept_bottom.width,
u_height=total_height,
starting_unit=starting_unit,
form_factor=concept_bottom.form_factor,
airflow=concept_bottom.airflow,
default_site=concept_bottom.default_site or concept_top.default_site,
default_location=concept_bottom.default_location or concept_top.default_location,
rack_role=concept_bottom.rack_role or concept_top.rack_role,
)
merged.full_clean()
merged.save()
warnings = []
def _copy_devices(source, shift):
for device in source.devices.all():
concept_device = ConceptDevice(
concept=merged,
name=device.name,
label=device.label,
status=device.status,
device_type=device.device_type,
role=device.role,
platform=device.platform,
tenant=device.tenant,
position=(device.position + shift) if device.position is not None else None,
face=device.face,
width_fraction=device.width_fraction,
horizontal_position=device.horizontal_position,
u_height=device.u_height,
is_full_depth=device.is_full_depth,
serial=device.serial,
description=device.description,
comments=device.comments,
source_device=device.source_device,
)
try:
concept_device.full_clean()
except ValidationError:
warnings.append(_(
'%(device)s from %(concept)s does not fit at its shifted position and was left out.'
) % {'device': device, 'concept': source})
continue
concept_device.save()
_copy_devices(concept_bottom, 0)
_copy_devices(concept_top, offset)
return merged, warnings
# #
+22 -3
View File
@@ -16,6 +16,9 @@ UNIT_HEIGHT = 22
RACK_WIDTH = 240 RACK_WIDTH = 240
LEGEND_WIDTH = 32 LEGEND_WIDTH = 32
MARGIN = 6 MARGIN = 6
# x where the rack frame itself starts. Used by the frame, every unit slot and every
# device block alike, so none of them can drift out of alignment with the others.
RACK_X = MARGIN + LEGEND_WIDTH
DEFAULT_COLOR = 'c0c0c0' DEFAULT_COLOR = 'c0c0c0'
RESERVED_COLOR = 'e9ecef' RESERVED_COLOR = 'e9ecef'
@@ -44,8 +47,9 @@ class ElevationUnit:
class ElevationBlock: class ElevationBlock:
"""One rendered device rectangle.""" """One rendered device rectangle."""
def __init__(self, device, x, y, width, height, opposite=False): def __init__(self, device, face, x, y, width, height, opposite=False):
self.device = device self.device = device
self.face = face
self.x = x self.x = x
self.y = y self.y = y
self.width = width self.width = width
@@ -80,6 +84,19 @@ class ElevationBlock:
def show_text(self): def show_text(self):
return self.height >= 14 return self.height >= 14
@property
def image(self):
"""
The device type's image for the face this block is drawn on, i.e. what's
physically visible from that side of the rack - not the device's own mounting
face, which only matters for half-depth devices occupying one face at a time.
"""
device_type = self.device.device_type
if device_type is None:
return None
image = device_type.front_image if self.face == DeviceFaceChoices.FACE_FRONT else device_type.rear_image
return image if image else None
class Elevation: class Elevation:
"""Everything the template needs to draw one face of a concept rack.""" """Everything the template needs to draw one face of a concept rack."""
@@ -92,7 +109,8 @@ class Elevation:
self.legend_width = LEGEND_WIDTH self.legend_width = LEGEND_WIDTH
self.rack_width = RACK_WIDTH self.rack_width = RACK_WIDTH
self.width = LEGEND_WIDTH + RACK_WIDTH + MARGIN * 2 self.rack_x = RACK_X
self.width = RACK_X + RACK_WIDTH + MARGIN
self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2 self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2
self._build_units() self._build_units()
@@ -128,7 +146,7 @@ class Elevation:
bottom, top = span bottom, top = span
lane_start, lane_end = device.lane_span lane_start, lane_end = device.lane_span
x = MARGIN + LEGEND_WIDTH + (lane_start / LANE_COUNT) * RACK_WIDTH x = RACK_X + (lane_start / LANE_COUNT) * RACK_WIDTH
width = ((lane_end - lane_start) / LANE_COUNT) * RACK_WIDTH width = ((lane_end - lane_start) / LANE_COUNT) * RACK_WIDTH
if self.concept.desc_units: if self.concept.desc_units:
@@ -138,6 +156,7 @@ class Elevation:
self.blocks.append(ElevationBlock( self.blocks.append(ElevationBlock(
device=device, device=device,
face=self.face,
x=x, x=x,
y=y, y=y,
width=width, width=width,
+95
View File
@@ -21,6 +21,7 @@ __all__ = (
'ConceptDeviceFilterForm', 'ConceptDeviceFilterForm',
'ConceptDeviceForm', 'ConceptDeviceForm',
'ConceptDeviceImportForm', 'ConceptDeviceImportForm',
'MergeConceptsForm',
'RackConceptBulkEditForm', 'RackConceptBulkEditForm',
'RackConceptCloneForm', 'RackConceptCloneForm',
'RackConceptFilterForm', 'RackConceptFilterForm',
@@ -318,14 +319,108 @@ class RackCopyForm(forms.Form):
label=_('Keep device names'), label=_('Keep device names'),
help_text=_('Unchecked, only the device type and position are copied') help_text=_('Unchecked, only the device type and position are copied')
) )
override_width = forms.ChoiceField(
choices=(('', _('Same as rack')),) + tuple(RackWidthChoices),
required=False,
label=_('Width')
)
override_u_height = forms.IntegerField(
required=False,
min_value=1,
max_value=100,
label=_('Height (U)'),
help_text=_('Leave empty to keep the rack\'s own height. Devices that no longer fit are left out.')
)
override_starting_unit = forms.IntegerField(
required=False,
min_value=1,
label=_('Starting unit'),
help_text=_('Leave empty to keep the rack\'s own starting unit')
)
description = forms.CharField(max_length=200, required=False) description = forms.CharField(max_length=200, required=False)
fieldsets = ( fieldsets = (
FieldSet('name', 'status', 'description', name=_('Concept')), FieldSet('name', 'status', 'description', name=_('Concept')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet('include_devices', 'include_device_names', name=_('Content')), FieldSet('include_devices', 'include_device_names', name=_('Content')),
FieldSet(
'override_width', 'override_u_height', 'override_starting_unit',
name=_('Concept size (optional)')
),
) )
def clean_override_width(self):
value = self.cleaned_data['override_width']
return int(value) if value else None
class MergeConceptsForm(forms.Form):
"""Stack two blueprints (e.g. two half-height racks) into one taller concept."""
name = forms.CharField(
label=_('New 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')
)
concept_bottom = DynamicModelChoiceField(
queryset=RackConcept.objects.all(),
label=_('Bottom concept'),
help_text=_('Keeps its planned positions unchanged')
)
concept_top = DynamicModelChoiceField(
queryset=RackConcept.objects.all(),
label=_('Top concept'),
help_text=_("Its devices are shifted up by the bottom concept's height")
)
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('concept_bottom', 'concept_top', name=_('Sources')),
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('concept_bottom'), cleaned.get('concept_top')
if bottom and top and bottom.pk == top.pk:
raise forms.ValidationError(_('Choose two different concepts to merge.'))
return cleaned
class RackConceptCloneForm(forms.Form): class RackConceptCloneForm(forms.Form):
"""Duplicate a blueprint, for building variants.""" """Duplicate a blueprint, for building variants."""
+7
View File
@@ -18,6 +18,13 @@ concept_buttons = [
color=ButtonColorChoices.CYAN, color=ButtonColorChoices.CYAN,
permissions=['netbox_rack_concept.add_rackconcept'] permissions=['netbox_rack_concept.add_rackconcept']
), ),
PluginMenuButton(
link='plugins:netbox_rack_concept:rackconcept_merge',
title=_('Merge'),
icon_class='mdi mdi-call-merge',
color=ButtonColorChoices.PURPLE,
permissions=['netbox_rack_concept.add_rackconcept']
),
] ]
device_buttons = [ device_buttons = [
@@ -37,11 +37,25 @@
stroke-width: 1; stroke-width: 1;
} }
.concept-elevation .device-image {
pointer-events: none;
}
.concept-elevation .device-label-bg {
fill: #000000;
fill-opacity: 0.45;
pointer-events: none;
}
.concept-elevation .device-label { .concept-elevation .device-label {
font-size: 11px; font-size: 11px;
pointer-events: none; pointer-events: none;
} }
.concept-elevation .device-label.on-image {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.concept-elevation .opposite-hint { .concept-elevation .opposite-hint {
font-size: 9px; font-size: 9px;
opacity: 0.8; opacity: 0.8;
@@ -0,0 +1,28 @@
{% extends 'base/layout.html' %}
{% load i18n %}
{% load form_helpers %}
{% block title %}{% trans "Merge Rack Concepts" %}{% endblock %}
{% block content %}
<form action="" method="post">
{% csrf_token %}
<div class="row">
<div class="col col-md-8 offset-md-2">
<div class="card">
<h2 class="card-header">{% trans "Merge two concepts into one" %}</h2>
<div class="card-body">
<p class="text-muted">
{% trans "Stacks the bottom concept's devices unchanged and shifts the top concept's devices up by the bottom concept's height, into one new, taller blueprint. Neither source concept is modified." %}
</p>
{% render_form form %}
</div>
<div class="card-footer text-end">
<a href="{{ return_url }}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary">{% trans "Merge" %}</button>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
@@ -15,11 +15,11 @@
{# Unit rows and legend #} {# Unit rows and legend #}
{% for unit in elevation.units %} {% for unit in elevation.units %}
<g class="unit"> <g class="unit">
<rect x="{{ elevation.legend_width }}" y="{{ unit.y }}" <rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}" width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot" class="unit-slot"
data-unit="{{ unit.number }}"></rect> data-unit="{{ unit.number }}"></rect>
<text x="{{ elevation.legend_width }}" y="{{ unit.y }}" dx="-4" dy="15" <text x="{{ elevation.rack_x }}" y="{{ unit.y }}" dx="-4" dy="15"
class="unit-label" text-anchor="end">{{ unit.number }}</text> class="unit-label" text-anchor="end">{{ unit.number }}</text>
</g> </g>
{% endfor %} {% endfor %}
@@ -32,13 +32,31 @@
data-u-height="{{ block.device.effective_u_height }}" data-u-height="{{ block.device.effective_u_height }}"
data-width-fraction="{{ block.device.width_fraction }}"> data-width-fraction="{{ block.device.width_fraction }}">
<a href="{{ block.device.get_absolute_url }}"> <a href="{{ block.device.get_absolute_url }}">
{% if block.image %}
<clipPath id="concept-block-{{ elevation.face }}-{{ block.device.pk }}">
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}" rx="2"></rect>
</clipPath>
{% endif %}
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}" <rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}"
rx="2" fill="#{{ block.color }}" rx="2" fill="#{{ block.color }}"
fill-opacity="{% if block.opposite %}0.35{% else %}1{% endif %}" fill-opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
class="device-rect"></rect> class="device-rect"></rect>
{% if block.image %}
<image href="{{ block.image.url }}" x="{{ block.x }}" y="{{ block.y }}"
width="{{ block.width }}" height="{{ block.height }}"
preserveAspectRatio="xMidYMid slice"
clip-path="url(#concept-block-{{ elevation.face }}-{{ block.device.pk }})"
opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
class="device-image"></image>
{% endif %}
{% if block.show_text %} {% if block.show_text %}
{% if block.image %}
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="16"
class="device-label-bg"></rect>
{% endif %}
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6" <text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
fill="#{{ block.text_color }}" class="device-label"> fill="{% if block.image %}#ffffff{% else %}#{{ block.text_color }}{% endif %}"
class="device-label {% if block.image %}on-image{% endif %}">
{{ block.label }}{% if block.opposite %} <tspan class="opposite-hint">({% trans "rear" %})</tspan>{% endif %} {{ block.label }}{% if block.opposite %} <tspan class="opposite-hint">({% trans "rear" %})</tspan>{% endif %}
</text> </text>
{% endif %} {% endif %}
@@ -47,7 +65,7 @@
</g> </g>
{% endfor %} {% endfor %}
<rect x="{{ elevation.legend_width }}" y="6" <rect x="{{ elevation.rack_x }}" y="6"
width="{{ elevation.rack_width }}" height="{{ elevation.height|add:'-12' }}" width="{{ elevation.rack_width }}" height="{{ elevation.height|add:'-12' }}"
class="rack-frame" fill="none"></rect> class="rack-frame" fill="none"></rect>
</svg> </svg>
@@ -15,6 +15,9 @@
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_clone_concept' pk=object.pk %}" class="btn btn-outline-secondary"> <a href="{% url 'plugins:netbox_rack_concept:rackconcept_clone_concept' pk=object.pk %}" class="btn btn-outline-secondary">
<i class="mdi mdi-content-duplicate"></i> {% trans "Clone concept" %} <i class="mdi mdi-content-duplicate"></i> {% trans "Clone concept" %}
</a> </a>
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_merge' %}?concept_bottom={{ object.pk }}" class="btn btn-outline-secondary">
<i class="mdi mdi-call-merge"></i> {% trans "Merge with another" %}
</a>
{% endif %} {% endif %}
{% if perms.dcim.add_rack %} {% if perms.dcim.add_rack %}
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_deploy' pk=object.pk %}" class="btn btn-primary"> <a href="{% url 'plugins:netbox_rack_concept:rackconcept_deploy' pk=object.pk %}" class="btn btn-primary">
+3
View File
@@ -9,6 +9,9 @@ app_name = 'netbox_rack_concept'
urlpatterns = [ urlpatterns = [
# Rack concepts # Rack concepts
path('concepts/', include(get_model_urls('netbox_rack_concept', 'rackconcept', detail=False))), path('concepts/', include(get_model_urls('netbox_rack_concept', 'rackconcept', detail=False))),
# Combine two concepts into a taller one. Listed before the <int:pk>/ include below only
# for readability; 'merge' can never match the int converter anyway.
path('concepts/merge/', views.MergeConceptsView.as_view(), name='rackconcept_merge'),
path('concepts/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'rackconcept'))), path('concepts/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'rackconcept'))),
# Planned devices # Planned devices
+59 -1
View File
@@ -22,6 +22,7 @@ from .models import ConceptDevice, RackConcept
__all__ = ( __all__ = (
'ConceptDeviceView', 'ConceptDeviceView',
'MergeConceptsView',
'RackConceptView', 'RackConceptView',
) )
@@ -201,7 +202,7 @@ class RackCopyView(ObjectPermissionRequiredMixin, View):
if form.is_valid(): if form.is_valid():
try: try:
concept = deploy.copy_rack_to_concept( concept, warnings = deploy.copy_rack_to_concept(
rack=rack, rack=rack,
name=form.cleaned_data['name'], name=form.cleaned_data['name'],
status=form.cleaned_data['status'], status=form.cleaned_data['status'],
@@ -210,10 +211,15 @@ class RackCopyView(ObjectPermissionRequiredMixin, View):
description=form.cleaned_data['description'], description=form.cleaned_data['description'],
include_devices=form.cleaned_data['include_devices'], include_devices=form.cleaned_data['include_devices'],
include_device_names=form.cleaned_data['include_device_names'], 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: except ValidationError as exc:
form.add_error(None, exc) form.add_error(None, exc)
else: else:
for warning in warnings:
messages.warning(request, warning)
messages.success(request, _('Created concept %(concept)s from rack %(rack)s.') % { messages.success(request, _('Created concept %(concept)s from rack %(rack)s.') % {
'concept': concept, 'rack': rack 'concept': concept, 'rack': rack
}) })
@@ -227,6 +233,58 @@ class RackCopyView(ObjectPermissionRequiredMixin, View):
}) })
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'),
})
@register_model_view(RackConcept, 'clone_concept', path='clone') @register_model_view(RackConcept, 'clone_concept', path='clone')
class RackConceptCloneView(ObjectPermissionRequiredMixin, View): class RackConceptCloneView(ObjectPermissionRequiredMixin, View):
"""Duplicate a concept to build a variant.""" """Duplicate a concept to build a variant."""