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:
@@ -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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user