Add images/labels toggle, clean up label styling, merge real racks
- Replace the device-image label background bar and text-shadow with NetBox's own technique: a black-stroke pass behind a white-fill pass of the same text, giving crisp readable labels over any image without a dark box behind them. - Add an "Images and labels / Images only / Labels only" selector above the elevation, mirroring the equivalent control on real NetBox rack elevations. The plain label is always in the markup; the selector only toggles the image and its over-image label, exactly like core NetBox does it. Choice persists per browser via localStorage and works without edit permissions. - Add a "Merge into concept" button to the real dcim Rack list: select two racks via the existing bulk-select checkboxes and it forwards them to the concept-merge page, pre-filled. New deploy.merge_racks_to_concept() combines two real racks' devices directly into one new, stacked blueprint without an intermediate per-rack copy step. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
| 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 |
|
| 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 → 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 → +** |
|
||||||
|
|
||||||
@@ -23,8 +24,10 @@ 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
|
* **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 im Elevation-Block angezeigt; darüber liegt der Gerätename als weiße, schwarz
|
||||||
es bei der reinen Farbfläche mit Text.
|
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** (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.
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
The four copy paths: rack -> concept, concept -> concept, concept -> rack(s), and
|
The five copy paths: rack -> concept, concept -> concept, concept -> rack(s),
|
||||||
concept + concept -> one merged concept.
|
concept + concept -> one merged concept, and rack + rack -> one merged concept.
|
||||||
"""
|
"""
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
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 _
|
||||||
|
|
||||||
|
from dcim.choices import RackWidthChoices
|
||||||
from dcim.models import Device, Rack
|
from dcim.models import Device, Rack
|
||||||
|
|
||||||
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices
|
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices
|
||||||
@@ -18,6 +19,7 @@ __all__ = (
|
|||||||
'copy_rack_to_concept',
|
'copy_rack_to_concept',
|
||||||
'deploy_concept',
|
'deploy_concept',
|
||||||
'merge_concepts',
|
'merge_concepts',
|
||||||
|
'merge_racks_to_concept',
|
||||||
'validate_deployment',
|
'validate_deployment',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +33,14 @@ def _config():
|
|||||||
return settings.PLUGINS_CONFIG.get('netbox_rack_concept', {})
|
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
|
# dcim.Rack -> RackConcept
|
||||||
#
|
#
|
||||||
@@ -59,10 +69,7 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t
|
|||||||
source_rack=rack,
|
source_rack=rack,
|
||||||
)
|
)
|
||||||
for field in GEOMETRY_FIELDS:
|
for field in GEOMETRY_FIELDS:
|
||||||
value = getattr(rack, field, None)
|
value = _rack_geometry(rack, field)
|
||||||
# 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)
|
|
||||||
if value not in (None, ''):
|
if value not in (None, ''):
|
||||||
setattr(concept, field, value)
|
setattr(concept, field, value)
|
||||||
|
|
||||||
@@ -190,6 +197,83 @@ def merge_concepts(concept_bottom, concept_top, name, status=ConceptStatusChoice
|
|||||||
return merged, warnings
|
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
|
# RackConcept -> RackConcept
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ __all__ = (
|
|||||||
'ConceptDeviceForm',
|
'ConceptDeviceForm',
|
||||||
'ConceptDeviceImportForm',
|
'ConceptDeviceImportForm',
|
||||||
'MergeConceptsForm',
|
'MergeConceptsForm',
|
||||||
|
'MergeRacksForm',
|
||||||
'RackConceptBulkEditForm',
|
'RackConceptBulkEditForm',
|
||||||
'RackConceptCloneForm',
|
'RackConceptCloneForm',
|
||||||
'RackConceptFilterForm',
|
'RackConceptFilterForm',
|
||||||
@@ -422,6 +423,85 @@ class MergeConceptsForm(forms.Form):
|
|||||||
return cleaned
|
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):
|
class RackConceptCloneForm(forms.Form):
|
||||||
"""Duplicate a blueprint, for building variants."""
|
"""Duplicate a blueprint, for building variants."""
|
||||||
name = forms.CharField(
|
name = forms.CharField(
|
||||||
|
|||||||
@@ -41,19 +41,32 @@
|
|||||||
pointer-events: none;
|
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 {
|
/* Label drawn over an image: a black stroke pass behind a white fill pass gives crisp,
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
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 {
|
.concept-elevation .opposite-hint {
|
||||||
|
|||||||
@@ -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.
|
* Drag & drop for concept rack elevations.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* "Merge into concept" button on the real dcim Rack list.
|
||||||
|
*
|
||||||
|
* The button lives outside NetBox's own bulk-action <form> (plugin list buttons render
|
||||||
|
* in the page header, the row checkboxes further down in the table's form), so it
|
||||||
|
* can't rely on a normal form submit to collect the selection. Instead it reads the
|
||||||
|
* checked "pk" checkboxes straight out of the DOM - the same input name NetBox's own
|
||||||
|
* bulk-edit/bulk-delete buttons use - and forwards up to two of them as query
|
||||||
|
* parameters to the merge page, which pre-fills its two rack fields from that.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const button = document.getElementById('concept-merge-racks-button');
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedRackIds() {
|
||||||
|
return Array.prototype.map.call(
|
||||||
|
document.querySelectorAll('input[name="pk"]:checked'),
|
||||||
|
function (el) { return el.value; }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.addEventListener('click', function (event) {
|
||||||
|
const ids = checkedRackIds();
|
||||||
|
if (ids.length === 0) {
|
||||||
|
// Nothing selected: fall through to the plain link, let the user pick
|
||||||
|
// both racks by hand on the merge page instead.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ids.length > 2) {
|
||||||
|
event.preventDefault();
|
||||||
|
window.alert(
|
||||||
|
'Please select exactly two racks to merge (you selected ' + ids.length + ').'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const url = new URL(button.dataset.mergeUrl, window.location.origin);
|
||||||
|
url.searchParams.set('rack_bottom', ids[0]);
|
||||||
|
if (ids.length === 2) {
|
||||||
|
url.searchParams.set('rack_top', ids[1]);
|
||||||
|
}
|
||||||
|
window.location.href = url.toString();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -10,7 +10,10 @@ def _enabled():
|
|||||||
|
|
||||||
|
|
||||||
class RackConceptButtons(PluginTemplateExtension):
|
class RackConceptButtons(PluginTemplateExtension):
|
||||||
"""Adds "Copy to concept" to the button row of a rack, and lists derived concepts."""
|
"""
|
||||||
|
Adds "Copy to concept" to the button row of a rack, "Merge into concept" to the
|
||||||
|
rack list's toolbar, and lists concepts derived from a rack.
|
||||||
|
"""
|
||||||
models = ['dcim.rack']
|
models = ['dcim.rack']
|
||||||
|
|
||||||
def buttons(self):
|
def buttons(self):
|
||||||
@@ -22,6 +25,13 @@ class RackConceptButtons(PluginTemplateExtension):
|
|||||||
'rack': self.context['object'],
|
'rack': self.context['object'],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def list_buttons(self):
|
||||||
|
if not _enabled():
|
||||||
|
return ''
|
||||||
|
if not self.context['request'].user.has_perm('netbox_rack_concept.add_rackconcept'):
|
||||||
|
return ''
|
||||||
|
return self.render('netbox_rack_concept/inc/rack_list_merge_button.html')
|
||||||
|
|
||||||
def right_page(self):
|
def right_page(self):
|
||||||
if not _enabled():
|
if not _enabled():
|
||||||
return ''
|
return ''
|
||||||
|
|||||||
@@ -32,33 +32,38 @@
|
|||||||
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>
|
||||||
|
{% comment %}
|
||||||
|
The plain label is always in the markup, image or not - toggling "images
|
||||||
|
only" just layers the (opaque) image on top of it, exactly like NetBox's
|
||||||
|
own rack elevation does. That way there is one code path, not two.
|
||||||
|
{% endcomment %}
|
||||||
|
{% if block.show_text %}
|
||||||
|
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
|
||||||
|
fill="#{{ block.text_color }}" class="device-label">
|
||||||
|
{{ block.label }}{% if block.opposite %} <tspan class="opposite-hint">({% trans "rear" %})</tspan>{% endif %}
|
||||||
|
</text>
|
||||||
|
{% endif %}
|
||||||
{% if block.image %}
|
{% 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>
|
||||||
<image href="{{ block.image.url }}" x="{{ block.x }}" y="{{ block.y }}"
|
<image href="{{ block.image.url }}" x="{{ block.x }}" y="{{ block.y }}"
|
||||||
width="{{ block.width }}" height="{{ block.height }}"
|
width="{{ block.width }}" height="{{ block.height }}"
|
||||||
preserveAspectRatio="xMidYMid slice"
|
preserveAspectRatio="xMidYMid slice"
|
||||||
clip-path="url(#concept-block-{{ elevation.face }}-{{ block.device.pk }})"
|
clip-path="url(#concept-block-{{ elevation.face }}-{{ block.device.pk }})"
|
||||||
opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
|
opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
|
||||||
class="device-image"></image>
|
class="device-image"></image>
|
||||||
{% endif %}
|
{% if block.show_text %}
|
||||||
{% if block.show_text %}
|
{# Outlined white-on-black text, drawn as a stroke pass then a fill pass, so it reads over any image without a background box. #}
|
||||||
{% if block.image %}
|
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
|
||||||
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="16"
|
class="device-image-label device-image-label-stroke">{{ block.label }}</text>
|
||||||
class="device-label-bg"></rect>
|
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
|
||||||
|
class="device-image-label device-image-label-fill">{{ block.label }}</text>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
|
|
||||||
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 %}
|
|
||||||
</text>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<title>{{ block.label }}{% if block.sublabel %} · {{ block.sublabel }}{% endif %} · U{{ block.device.position }}</title>
|
<title>{{ block.label }}{% if block.sublabel %} · {{ block.sublabel }}{% endif %} · U{{ block.device.position }}</title>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
<a href="{% url 'plugins:netbox_rack_concept:racks_merge' %}" class="btn btn-outline-secondary"
|
||||||
|
id="concept-merge-racks-button" data-merge-url="{% url 'plugins:netbox_rack_concept:racks_merge' %}"
|
||||||
|
title="{% trans 'Select two racks below, then click this to combine them into one concept' %}">
|
||||||
|
<i class="mdi mdi-call-merge"></i> {% trans "Merge into concept" %}
|
||||||
|
</a>
|
||||||
|
<script src="{% static 'netbox_rack_concept/rack_list_merge.js' %}"></script>
|
||||||
@@ -128,12 +128,20 @@
|
|||||||
|
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="card-header">
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
{% trans "Elevation" %}
|
<h2 class="mb-0">{% trans "Elevation" %}</h2>
|
||||||
{% if perms.netbox_rack_concept.change_conceptdevice %}
|
<div class="d-flex align-items-center gap-2">
|
||||||
<span class="badge text-bg-secondary float-end">{% trans "Drag devices to move them" %}</span>
|
<select id="concept-elevation-view" class="form-select form-select-sm" style="width: auto;"
|
||||||
{% endif %}
|
aria-label="{% trans 'Images and labels' %}">
|
||||||
</h2>
|
<option value="images-and-labels">{% trans "Images and labels" %}</option>
|
||||||
|
<option value="images-only">{% trans "Images only" %}</option>
|
||||||
|
<option value="labels-only">{% trans "Labels only" %}</option>
|
||||||
|
</select>
|
||||||
|
{% if perms.netbox_rack_concept.change_conceptdevice %}
|
||||||
|
<span class="badge text-bg-secondary">{% trans "Drag devices to move them" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-around flex-wrap"
|
<div class="d-flex justify-content-around flex-wrap"
|
||||||
id="concept-elevations"
|
id="concept-elevations"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends 'base/layout.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load form_helpers %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Merge Racks into a Concept" %}{% 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 racks into one concept" %}</h2>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">
|
||||||
|
{% trans "Snapshots both racks' devices into one new blueprint - the top rack's devices shifted up by the bottom rack's height. Neither rack 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 "Create concept" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -18,6 +18,7 @@ urlpatterns = [
|
|||||||
path('devices/', include(get_model_urls('netbox_rack_concept', 'conceptdevice', detail=False))),
|
path('devices/', include(get_model_urls('netbox_rack_concept', 'conceptdevice', detail=False))),
|
||||||
path('devices/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'conceptdevice'))),
|
path('devices/<int:pk>/', include(get_model_urls('netbox_rack_concept', 'conceptdevice'))),
|
||||||
|
|
||||||
# Entry point from a real rack
|
# Entry points from real racks
|
||||||
path('racks/<int:pk>/copy/', views.RackCopyView.as_view(), name='rack_copy'),
|
path('racks/<int:pk>/copy/', views.RackCopyView.as_view(), name='rack_copy'),
|
||||||
|
path('racks/merge/', views.MergeRacksToConceptView.as_view(), name='racks_merge'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from .models import ConceptDevice, RackConcept
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
'ConceptDeviceView',
|
'ConceptDeviceView',
|
||||||
'MergeConceptsView',
|
'MergeConceptsView',
|
||||||
|
'MergeRacksToConceptView',
|
||||||
'RackConceptView',
|
'RackConceptView',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -285,6 +286,60 @@ class MergeConceptsView(ObjectPermissionRequiredMixin, View):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class MergeRacksToConceptView(ObjectPermissionRequiredMixin, View):
|
||||||
|
"""Combine two real racks directly into one new, stacked blueprint."""
|
||||||
|
queryset = RackConcept.objects.all()
|
||||||
|
|
||||||
|
def get_required_permission(self):
|
||||||
|
return 'netbox_rack_concept.add_rackconcept'
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
form = forms.MergeRacksForm(initial={
|
||||||
|
'rack_bottom': request.GET.get('rack_bottom'),
|
||||||
|
'rack_top': request.GET.get('rack_top'),
|
||||||
|
})
|
||||||
|
return render(request, 'netbox_rack_concept/racks_merge.html', {
|
||||||
|
'form': form,
|
||||||
|
'return_url': reverse('dcim:rack_list'),
|
||||||
|
})
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
form = forms.MergeRacksForm(request.POST)
|
||||||
|
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
concept, warnings = deploy.merge_racks_to_concept(
|
||||||
|
rack_bottom=form.cleaned_data['rack_bottom'],
|
||||||
|
rack_top=form.cleaned_data['rack_top'],
|
||||||
|
name=form.cleaned_data['name'],
|
||||||
|
status=form.cleaned_data['status'],
|
||||||
|
tenant=form.cleaned_data['tenant'],
|
||||||
|
tenant_group=form.cleaned_data['tenant_group'],
|
||||||
|
include_devices=form.cleaned_data['include_devices'],
|
||||||
|
include_device_names=form.cleaned_data['include_device_names'],
|
||||||
|
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 concept %(concept)s from %(bottom)s and %(top)s.') % {
|
||||||
|
'concept': concept,
|
||||||
|
'bottom': form.cleaned_data['rack_bottom'],
|
||||||
|
'top': form.cleaned_data['rack_top'],
|
||||||
|
})
|
||||||
|
return redirect(concept.get_absolute_url())
|
||||||
|
|
||||||
|
return render(request, 'netbox_rack_concept/racks_merge.html', {
|
||||||
|
'form': form,
|
||||||
|
'return_url': reverse('dcim:rack_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