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:
2026-09-16 09:50:23 +02:00
co-authored by Claude Sonnet 5
parent 777ded3900
commit e4817ea83b
13 changed files with 430 additions and 39 deletions
+5 -2
View File
@@ -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.
+90 -6
View File
@@ -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
# #
+80
View File
@@ -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();
});
})();
+11 -1
View File
@@ -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 %}
{% if block.image %} {# Outlined white-on-black text, drawn as a stroke pass then a fill pass, so it reads over any image without a background box. #}
<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="{% if block.image %}#ffffff{% else %}#{{ block.text_color }}{% endif %}" class="device-image-label device-image-label-stroke">{{ block.label }}</text>
class="device-label {% if block.image %}on-image{% endif %}"> <text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
{{ block.label }}{% if block.opposite %} <tspan class="opposite-hint">({% trans "rear" %})</tspan>{% endif %} class="device-image-label device-image-label-fill">{{ block.label }}</text>
</text> {% endif %}
{% endif %} {% endif %}
<title>{{ block.label }}{% if block.sublabel %} &middot; {{ block.sublabel }}{% endif %} &middot; U{{ block.device.position }}</title> <title>{{ block.label }}{% if block.sublabel %} &middot; {{ block.sublabel }}{% endif %} &middot; 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>
<div class="d-flex align-items-center gap-2">
<select id="concept-elevation-view" class="form-select form-select-sm" style="width: auto;"
aria-label="{% trans 'Images and labels' %}">
<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 %} {% if perms.netbox_rack_concept.change_conceptdevice %}
<span class="badge text-bg-secondary float-end">{% trans "Drag devices to move them" %}</span> <span class="badge text-bg-secondary">{% trans "Drag devices to move them" %}</span>
{% endif %} {% endif %}
</h2> </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 %}
+2 -1
View File
@@ -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'),
] ]
+55
View File
@@ -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."""