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:
@@ -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
|
||||
#
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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):
|
||||
"""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']
|
||||
|
||||
def buttons(self):
|
||||
@@ -22,6 +25,13 @@ class RackConceptButtons(PluginTemplateExtension):
|
||||
'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):
|
||||
if not _enabled():
|
||||
return ''
|
||||
|
||||
@@ -32,33 +32,38 @@
|
||||
data-u-height="{{ block.device.effective_u_height }}"
|
||||
data-width-fraction="{{ block.device.width_fraction }}">
|
||||
<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 }}"
|
||||
rx="2" fill="#{{ block.color }}"
|
||||
fill-opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
|
||||
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 %}
|
||||
<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 }}"
|
||||
width="{{ block.width }}" height="{{ block.height }}"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
clip-path="url(#concept-block-{{ elevation.face }}-{{ block.device.pk }})"
|
||||
opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
|
||||
class="device-image"></image>
|
||||
{% endif %}
|
||||
{% if block.show_text %}
|
||||
{% if block.image %}
|
||||
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="16"
|
||||
class="device-label-bg"></rect>
|
||||
{% 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. #}
|
||||
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
|
||||
class="device-image-label device-image-label-stroke">{{ block.label }}</text>
|
||||
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
|
||||
class="device-image-label device-image-label-fill">{{ block.label }}</text>
|
||||
{% 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 %}
|
||||
<title>{{ block.label }}{% if block.sublabel %} · {{ block.sublabel }}{% endif %} · U{{ block.device.position }}</title>
|
||||
</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="card">
|
||||
<h2 class="card-header">
|
||||
{% trans "Elevation" %}
|
||||
{% if perms.netbox_rack_concept.change_conceptdevice %}
|
||||
<span class="badge text-bg-secondary float-end">{% trans "Drag devices to move them" %}</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<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 %}
|
||||
<span class="badge text-bg-secondary">{% trans "Drag devices to move them" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-around flex-wrap"
|
||||
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/<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/merge/', views.MergeRacksToConceptView.as_view(), name='racks_merge'),
|
||||
]
|
||||
|
||||
@@ -23,6 +23,7 @@ from .models import ConceptDevice, RackConcept
|
||||
__all__ = (
|
||||
'ConceptDeviceView',
|
||||
'MergeConceptsView',
|
||||
'MergeRacksToConceptView',
|
||||
'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')
|
||||
class RackConceptCloneView(ObjectPermissionRequiredMixin, View):
|
||||
"""Duplicate a concept to build a variant."""
|
||||
|
||||
Reference in New Issue
Block a user