diff --git a/README.md b/README.md index 055adef..d1738c9 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Wenn die Planung steht, wird das Konzept per Knopfdruck in echte NetBox-Objekte | 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 | +| 2 echte Racks → 1 neues Konzept | Button **„Merge into concept"** über der echten Rack-Liste. Zwei Racks per Checkbox auswählen (wie bei jeder anderen Bulk-Aktion) und auf den Button klicken — vorausgefüllt landet man auf derselben Merge-Maske wie oben, diesmal direkt mit den zwei echten Racks als Quelle | | Konzept → echte Racks (n-fach) | Button **„Deploy to NetBox"**, mit Namensschema `DC1-R{n:02d}` | | Konzept von Hand anlegen | Menü **Rack Concepts → Rack Concepts → +** | @@ -23,8 +24,10 @@ 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. + es im Elevation-Block angezeigt; darüber liegt der Gerätename als weiße, schwarz + konturierte Schrift (kein dunkler Balken). Ein Dropdown über der Elevation schaltet + zwischen „Images and labels", „Images only" und „Labels only" um — genau wie bei + echten Racks in NetBox, inklusive Merken der Wahl im Browser. * **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. diff --git a/netbox_rack_concept/deploy.py b/netbox_rack_concept/deploy.py index e16f4dd..3e21b9f 100644 --- a/netbox_rack_concept/deploy.py +++ b/netbox_rack_concept/deploy.py @@ -1,12 +1,13 @@ """ -The four copy paths: rack -> concept, concept -> concept, concept -> rack(s), and -concept + concept -> one merged concept. +The five copy paths: rack -> concept, concept -> concept, concept -> rack(s), +concept + concept -> one merged concept, and rack + rack -> 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 _ +from dcim.choices import RackWidthChoices from dcim.models import Device, Rack from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices @@ -18,6 +19,7 @@ __all__ = ( 'copy_rack_to_concept', 'deploy_concept', 'merge_concepts', + 'merge_racks_to_concept', 'validate_deployment', ) @@ -31,6 +33,14 @@ def _config(): return settings.PLUGINS_CONFIG.get('netbox_rack_concept', {}) +def _rack_geometry(rack, field): + """A rack's own value for `field`, falling back to its rack type when left empty.""" + value = getattr(rack, field, None) + if value in (None, '') and rack.rack_type: + value = getattr(rack.rack_type, field, None) + return value + + # # dcim.Rack -> RackConcept # @@ -59,10 +69,7 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t source_rack=rack, ) for field in GEOMETRY_FIELDS: - value = getattr(rack, field, None) - # A rack built from a rack type may leave its own dimension fields empty. - if value in (None, '') and rack.rack_type: - value = getattr(rack.rack_type, field, None) + value = _rack_geometry(rack, field) if value not in (None, ''): setattr(concept, field, value) @@ -190,6 +197,83 @@ def merge_concepts(concept_bottom, concept_top, name, status=ConceptStatusChoice return merged, warnings +# +# dcim.Rack + dcim.Rack -> RackConcept (stacked) +# + +@transaction.atomic +def merge_racks_to_concept(rack_bottom, rack_top, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, + tenant_group=None, width=None, u_height=None, starting_unit=1, description='', + include_devices=True, include_device_names=True): + """ + Combine two real racks directly into one new, taller blueprint - e.g. two + half-height racks planned as one full-height rack. rack_bottom's devices keep their + position; rack_top's are shifted up by rack_bottom's height. Neither source rack is + modified. Returns (concept, warnings). + """ + bottom_height = _rack_geometry(rack_bottom, 'u_height') or rack_bottom.u_height + top_height = _rack_geometry(rack_top, 'u_height') or rack_top.u_height + total_height = u_height or (bottom_height + top_height) + + concept = RackConcept( + name=name, + status=status, + tenant=tenant, + tenant_group=tenant_group, + description=description, + width=width or _rack_geometry(rack_bottom, 'width') or RackWidthChoices.WIDTH_19IN, + u_height=total_height, + starting_unit=starting_unit, + form_factor=_rack_geometry(rack_bottom, 'form_factor') or '', + airflow=rack_bottom.airflow, + default_site=rack_bottom.site, + default_location=rack_bottom.location, + rack_role=rack_bottom.role, + source_rack=rack_bottom, + ) + concept.full_clean() + concept.save() + + warnings = [] + + def _copy_from_rack(rack, shift): + if not include_devices: + return + 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) + concept_device = ConceptDevice( + concept=concept, + name=(device.name or '') if include_device_names else '', + status=ConceptDeviceStatusChoices.STATUS_EXISTING, + device_type=device.device_type, + role=device.role, + platform=device.platform, + tenant=device.tenant, + position=device.position + shift, + face=device.face, + width_fraction=fraction, + horizontal_position=horizontal, + serial=device.serial if include_device_names else '', + source_device=device, + ) + try: + concept_device.full_clean() + except ValidationError: + warnings.append(_( + '%(device)s from %(rack)s does not fit at its shifted position and was left out.' + ) % {'device': device, 'rack': rack}) + continue + concept_device.save() + + _copy_from_rack(rack_bottom, 0) + _copy_from_rack(rack_top, bottom_height) + + return concept, warnings + + # # RackConcept -> RackConcept # diff --git a/netbox_rack_concept/forms.py b/netbox_rack_concept/forms.py index 5c09d78..4caf9a1 100644 --- a/netbox_rack_concept/forms.py +++ b/netbox_rack_concept/forms.py @@ -22,6 +22,7 @@ __all__ = ( 'ConceptDeviceForm', 'ConceptDeviceImportForm', 'MergeConceptsForm', + 'MergeRacksForm', 'RackConceptBulkEditForm', 'RackConceptCloneForm', 'RackConceptFilterForm', @@ -422,6 +423,85 @@ class MergeConceptsForm(forms.Form): return cleaned +class MergeRacksForm(forms.Form): + """Combine two existing racks directly into one new, stacked blueprint.""" + name = forms.CharField( + label=_('Concept name'), + max_length=100 + ) + status = forms.ChoiceField( + choices=ConceptStatusChoices, + initial=ConceptStatusChoices.STATUS_DRAFT + ) + tenant_group = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + label=_('Tenant group') + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + query_params={'group_id': '$tenant_group'}, + label=_('Tenant') + ) + rack_bottom = DynamicModelChoiceField( + queryset=Rack.objects.all(), + label=_('Bottom rack'), + help_text=_('Keeps its planned positions unchanged') + ) + rack_top = DynamicModelChoiceField( + queryset=Rack.objects.all(), + label=_('Top rack'), + help_text=_("Its devices are shifted up by the bottom rack's height") + ) + include_devices = forms.BooleanField( + required=False, + initial=True, + label=_('Copy installed devices') + ) + include_device_names = forms.BooleanField( + required=False, + initial=True, + label=_('Keep device names') + ) + width = forms.ChoiceField( + choices=RackWidthChoices, + initial=RackWidthChoices.WIDTH_19IN, + label=_('Width of the combined rack') + ) + u_height = forms.IntegerField( + required=False, + min_value=1, + max_value=100, + label=_('Height (U)'), + help_text=_('Leave empty to use the sum of both heights') + ) + starting_unit = forms.IntegerField( + initial=1, + min_value=1, + label=_('Starting unit') + ) + description = forms.CharField(max_length=200, required=False) + + fieldsets = ( + FieldSet('name', 'status', 'description', name=_('Concept')), + FieldSet('tenant_group', 'tenant', name=_('Tenancy')), + FieldSet('rack_bottom', 'rack_top', name=_('Sources')), + FieldSet('include_devices', 'include_device_names', name=_('Content')), + FieldSet('width', 'u_height', 'starting_unit', name=_('Combined size')), + ) + + def clean_width(self): + return int(self.cleaned_data['width']) + + def clean(self): + cleaned = super().clean() + bottom, top = cleaned.get('rack_bottom'), cleaned.get('rack_top') + if bottom and top and bottom.pk == top.pk: + raise forms.ValidationError(_('Choose two different racks to merge.')) + return cleaned + + class RackConceptCloneForm(forms.Form): """Duplicate a blueprint, for building variants.""" name = forms.CharField( diff --git a/netbox_rack_concept/static/netbox_rack_concept/elevation.css b/netbox_rack_concept/static/netbox_rack_concept/elevation.css index ab5dbd4..e632320 100644 --- a/netbox_rack_concept/static/netbox_rack_concept/elevation.css +++ b/netbox_rack_concept/static/netbox_rack_concept/elevation.css @@ -41,19 +41,32 @@ 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); +/* Label drawn over an image: a black stroke pass behind a white fill pass gives crisp, + readable text on any picture without a solid backing box. */ +.concept-elevation .device-image-label { + font-size: 11px; + pointer-events: none; +} + +.concept-elevation .device-image-label-stroke { + fill: none; + stroke: #000000; + stroke-width: 0.2em; + stroke-linejoin: round; +} + +.concept-elevation .device-image-label-fill { + fill: #ffffff; +} + +/* Toggled by the "images and labels / images only / labels only" selector. */ +.concept-elevation .hidden { + visibility: hidden; } .concept-elevation .opposite-hint { diff --git a/netbox_rack_concept/static/netbox_rack_concept/elevation.js b/netbox_rack_concept/static/netbox_rack_concept/elevation.js index 33ea6d7..3b601a3 100644 --- a/netbox_rack_concept/static/netbox_rack_concept/elevation.js +++ b/netbox_rack_concept/static/netbox_rack_concept/elevation.js @@ -1,3 +1,50 @@ +/* + * View selector: show/hide device images and their labels, mirroring the + * "images-and-labels / images-only / labels-only" control on NetBox's own rack + * elevation. The plain (non-image) label is never toggled by this - it's always in the + * markup, and an opaque image simply sits on top of it, exactly as NetBox does it. + * Runs independently of edit permissions, so it works for read-only users too. + */ +(function () { + 'use strict'; + + const STORAGE_KEY = 'netbox_rack_concept:elevation-view'; + const select = document.getElementById('concept-elevation-view'); + const container = document.getElementById('concept-elevations'); + if (!select || !container) { + return; + } + + function applyView(view) { + const showImages = view !== 'labels-only'; + const showImageLabels = view === 'images-and-labels'; + container.querySelectorAll('image.device-image').forEach(function (el) { + el.classList.toggle('hidden', !showImages); + }); + container.querySelectorAll('.device-image-label').forEach(function (el) { + el.classList.toggle('hidden', !showImageLabels); + }); + } + + let initial = 'images-and-labels'; + try { + initial = window.localStorage.getItem(STORAGE_KEY) || initial; + } catch (e) { + // Storage may be unavailable (private browsing, blocked cookies); fall back silently. + } + select.value = initial; + applyView(initial); + + select.addEventListener('change', function () { + applyView(select.value); + try { + window.localStorage.setItem(STORAGE_KEY, select.value); + } catch (e) { + // Nothing to do if storage isn't available - the choice just won't persist. + } + }); +})(); + /* * Drag & drop for concept rack elevations. * diff --git a/netbox_rack_concept/static/netbox_rack_concept/rack_list_merge.js b/netbox_rack_concept/static/netbox_rack_concept/rack_list_merge.js new file mode 100644 index 0000000..80ed2a0 --- /dev/null +++ b/netbox_rack_concept/static/netbox_rack_concept/rack_list_merge.js @@ -0,0 +1,49 @@ +/* + * "Merge into concept" button on the real dcim Rack list. + * + * The button lives outside NetBox's own bulk-action