diff --git a/README.md b/README.md index c22c618..055adef 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ Wenn die Planung steht, wird das Konzept per Knopfdruck in echte NetBox-Objekte | 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 | +| 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 von Hand anlegen | Menü **Rack Concepts → Rack Concepts → +** | @@ -21,6 +22,9 @@ Dazu: * **Elevation mit Drag & Drop** – Geräte per Maus verschieben, serverseitig validiert. 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-Feature von `netbox_utilities`. * **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/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/merge/` | Zwei Konzepte zu einem größeren stapeln (`concept_bottom`, `concept_top`) | | `POST` | `concepts/{id}/deploy/` | Konzept ausrollen (`dry_run` möglich) | | `GET` | `concepts/{id}/elevation/` | Berechnete Elevation als JSON | | `GET/POST` | `devices/` | Geplante Geräte | diff --git a/netbox_rack_concept/api/serializers.py b/netbox_rack_concept/api/serializers.py index c433590..328d8c3 100644 --- a/netbox_rack_concept/api/serializers.py +++ b/netbox_rack_concept/api/serializers.py @@ -4,6 +4,7 @@ from dcim.api.serializers import ( DeviceRoleSerializer, DeviceTypeSerializer, LocationSerializer, PlatformSerializer, RackRoleSerializer, RackSerializer, RackTypeSerializer, SiteSerializer, ) +from dcim.choices import RackWidthChoices from dcim.models import Location, Rack, Site from netbox.api.serializers import NetBoxModelSerializer 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='') include_devices = 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): @@ -103,6 +107,24 @@ class RackConceptCloneSerializer(serializers.Serializer): 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): """POST body for materialising a concept.""" name_pattern = serializers.CharField(max_length=100) diff --git a/netbox_rack_concept/api/views.py b/netbox_rack_concept/api/views.py index 6b645d6..652e2cd 100644 --- a/netbox_rack_concept/api/views.py +++ b/netbox_rack_concept/api/views.py @@ -13,8 +13,8 @@ from .. import deploy as deploy_utils from ..elevation import get_elevations from ..models import ConceptDevice, RackConcept from .serializers import ( - ConceptDeploySerializer, ConceptDeviceSerializer, RackConceptCloneSerializer, RackConceptSerializer, - RackCopySerializer, + ConceptDeploySerializer, ConceptDeviceSerializer, MergeConceptsSerializer, RackConceptCloneSerializer, + RackConceptSerializer, RackCopySerializer, ) __all__ = ( @@ -41,7 +41,7 @@ class RackConceptViewSet(NetBoxModelViewSet): tenant = data.get('tenant') try: - concept = deploy_utils.copy_rack_to_concept( + concept, warnings = deploy_utils.copy_rack_to_concept( rack=rack, name=data['name'], status=data['status'], @@ -50,15 +50,17 @@ class RackConceptViewSet(NetBoxModelViewSet): description=data['description'], include_devices=data['include_devices'], 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: return Response({'errors': exc.message_dict if hasattr(exc, 'message_dict') else exc.messages}, status=status.HTTP_400_BAD_REQUEST) - return Response( - RackConceptSerializer(concept, context={'request': request}).data, - status=status.HTTP_201_CREATED - ) + result = RackConceptSerializer(concept, context={'request': request}).data + result['warnings'] = warnings + return Response(result, status=status.HTTP_201_CREATED) @action(detail=True, methods=['post']) def clone(self, request, pk=None): @@ -85,6 +87,35 @@ class RackConceptViewSet(NetBoxModelViewSet): 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']) def deploy(self, request, pk=None): """ diff --git a/netbox_rack_concept/deploy.py b/netbox_rack_concept/deploy.py index 052b096..e16f4dd 100644 --- a/netbox_rack_concept/deploy.py +++ b/netbox_rack_concept/deploy.py @@ -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.core.exceptions import ValidationError from django.db import transaction from django.utils.translation import gettext as _ @@ -15,6 +17,7 @@ __all__ = ( 'clone_concept', 'copy_rack_to_concept', 'deploy_concept', + 'merge_concepts', 'validate_deployment', ) @@ -34,8 +37,15 @@ def _config(): @transaction.atomic def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, tenant_group=None, - description='', include_devices=True, include_device_names=True): - """Snapshot a real rack, and optionally its contents, into a new blueprint.""" + description='', include_devices=True, include_device_names=True, + 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( name=name, 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) if value not in (None, ''): 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.save() + warnings = [] if include_devices: devices = Device.objects.filter(rack=rack, position__isnull=False).select_related( 'device_type', 'role', 'platform', 'tenant' ) for device in devices: fraction, horizontal = read_partial_width(device) - ConceptDevice.objects.create( + concept_device = ConceptDevice( concept=concept, name=(device.name or '') if include_device_names else '', 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 '', 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 # diff --git a/netbox_rack_concept/elevation.py b/netbox_rack_concept/elevation.py index 95cc5fa..db6e451 100644 --- a/netbox_rack_concept/elevation.py +++ b/netbox_rack_concept/elevation.py @@ -16,6 +16,9 @@ UNIT_HEIGHT = 22 RACK_WIDTH = 240 LEGEND_WIDTH = 32 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' RESERVED_COLOR = 'e9ecef' @@ -44,8 +47,9 @@ class ElevationUnit: class ElevationBlock: """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.face = face self.x = x self.y = y self.width = width @@ -80,6 +84,19 @@ class ElevationBlock: def show_text(self): 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: """Everything the template needs to draw one face of a concept rack.""" @@ -92,7 +109,8 @@ class Elevation: self.legend_width = LEGEND_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._build_units() @@ -128,7 +146,7 @@ class Elevation: bottom, top = 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 if self.concept.desc_units: @@ -138,6 +156,7 @@ class Elevation: self.blocks.append(ElevationBlock( device=device, + face=self.face, x=x, y=y, width=width, diff --git a/netbox_rack_concept/forms.py b/netbox_rack_concept/forms.py index f3988b5..5c09d78 100644 --- a/netbox_rack_concept/forms.py +++ b/netbox_rack_concept/forms.py @@ -21,6 +21,7 @@ __all__ = ( 'ConceptDeviceFilterForm', 'ConceptDeviceForm', 'ConceptDeviceImportForm', + 'MergeConceptsForm', 'RackConceptBulkEditForm', 'RackConceptCloneForm', 'RackConceptFilterForm', @@ -318,14 +319,108 @@ class RackCopyForm(forms.Form): label=_('Keep device names'), 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) fieldsets = ( FieldSet('name', 'status', 'description', name=_('Concept')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), 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): """Duplicate a blueprint, for building variants.""" diff --git a/netbox_rack_concept/navigation.py b/netbox_rack_concept/navigation.py index c420717..bd90061 100644 --- a/netbox_rack_concept/navigation.py +++ b/netbox_rack_concept/navigation.py @@ -18,6 +18,13 @@ concept_buttons = [ color=ButtonColorChoices.CYAN, 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 = [ diff --git a/netbox_rack_concept/static/netbox_rack_concept/elevation.css b/netbox_rack_concept/static/netbox_rack_concept/elevation.css index 88bfeb9..ab5dbd4 100644 --- a/netbox_rack_concept/static/netbox_rack_concept/elevation.css +++ b/netbox_rack_concept/static/netbox_rack_concept/elevation.css @@ -37,11 +37,25 @@ 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 { font-size: 11px; pointer-events: none; } +.concept-elevation .device-label.on-image { + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); +} + .concept-elevation .opposite-hint { font-size: 9px; opacity: 0.8; diff --git a/netbox_rack_concept/templates/netbox_rack_concept/concept_merge.html b/netbox_rack_concept/templates/netbox_rack_concept/concept_merge.html new file mode 100644 index 0000000..602cc82 --- /dev/null +++ b/netbox_rack_concept/templates/netbox_rack_concept/concept_merge.html @@ -0,0 +1,28 @@ +{% extends 'base/layout.html' %} +{% load i18n %} +{% load form_helpers %} + +{% block title %}{% trans "Merge Rack Concepts" %}{% endblock %} + +{% block content %} +
+{% endblock %} diff --git a/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html b/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html index 4cdda51..9d8ffb9 100644 --- a/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html +++ b/netbox_rack_concept/templates/netbox_rack_concept/inc/elevation.html @@ -15,11 +15,11 @@ {# Unit rows and legend #} {% for unit in elevation.units %}