Add netbox_rack_concept plugin: rack blueprint planning

Introduces a NetBox 4.6 plugin for planning rack layouts (RackConcept,
ConceptDevice) without creating real dcim.Rack/dcim.Device objects, so
blueprints never appear in rack/device lists, utilization reports, or
IPAM.

Features:
- Copy paths: real rack -> concept, concept -> concept (clone), and
  concept -> one or more real racks (deploy, with name patterning and
  dry-run support)
- Manual concept creation via a dedicated "Rack Concepts" nav menu
- Tenant and tenant group assignment on concepts and planned devices
- Partial-width device placement (1/1-1/4) with slot-based collision
  detection, matching netbox_utilities' partial-width feature
- Custom drag-and-drop elevation (netbox-reorder-rack operates on real
  Device querysets, which concepts don't have) with server-side
  validation of every move
- Full REST API, filtering, CSV import, bulk edit, search integration
- Optional, config-driven interop with netbox_utilities (partial-width
  field mapping) and netbox-reorder-rack, degrading gracefully when
  either plugin is absent

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 09:00:26 +02:00
co-authored by Claude Sonnet 5
commit 17cf94ac0d
34 changed files with 3821 additions and 0 deletions
@@ -0,0 +1,32 @@
{% extends 'base/layout.html' %}
{% load i18n %}
{% load form_helpers %}
{% block title %}{% blocktrans with name=object.name %}Clone {{ name }}{% endblocktrans %}{% 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">{% blocktrans with name=object.name %}Clone concept {{ name }}{% endblocktrans %}</h2>
<div class="card-body">
<p class="text-muted">
{% blocktrans count counter=object.devices.count %}
The original keeps its {{ counter }} planned device. The clone is independent from this point on.
{% plural %}
The original keeps its {{ counter }} planned devices. The clone is independent from this point on.
{% endblocktrans %}
</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 clone" %}</button>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
@@ -0,0 +1,48 @@
{% extends 'base/layout.html' %}
{% load i18n %}
{% load form_helpers %}
{% block title %}{% blocktrans with name=object.name %}Deploy {{ name }}{% endblocktrans %}{% 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">{% blocktrans with name=object.name %}Deploy concept {{ name }}{% endblocktrans %}</h2>
<div class="card-body">
<p class="text-muted">
{% trans "This creates real racks and devices in NetBox. The concept itself is left untouched and stays reusable." %}
{% blocktrans count counter=deployable_count %}
{{ counter }} planned device will be created per rack.
{% plural %}
{{ counter }} planned devices will be created per rack.
{% endblocktrans %}
</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 "Deploy" %}</button>
</div>
</div>
{% if warnings %}
<div class="card">
<h2 class="card-header text-warning">
<i class="mdi mdi-alert"></i> {% trans "Will be skipped" %}
</h2>
<ul class="list-group list-group-flush">
{% for warning in warnings %}
<li class="list-group-item">{{ warning }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
</form>
{% endblock %}
@@ -0,0 +1,102 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Planned Device" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Concept" %}</th>
<td>{{ object.concept|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Label" %}</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>{{ object.tenant|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Copied from" %}</th>
<td>{{ object.source_device|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/comments.html' %}
</div>
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Hardware" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.device_type.manufacturer|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Device type" %}</th>
<td>{{ object.device_type|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{{ object.role|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Platform" %}</th>
<td>{{ object.platform|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Serial" %}</th>
<td>{{ object.serial|placeholder }}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Placement" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Position" %}</th>
<td>{% if object.position %}U{{ object.position }}{% else %}{% trans "Not racked" %}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "Height" %}</th>
<td>{{ object.effective_u_height }}U</td>
</tr>
<tr>
<th scope="row">{% trans "Face" %}</th>
<td>{{ object.get_face_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Width" %}</th>
<td>{{ object.get_width_fraction_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Horizontal position" %}</th>
<td>{{ object.horizontal_position }}</td>
</tr>
<tr>
<th scope="row">{% trans "Full depth" %}</th>
<td>{% checkmark object.effective_full_depth %}</td>
</tr>
</table>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,55 @@
{% load i18n %}
{% load l10n %}
{% comment %}
One face of a concept rack. Localization is switched off below because SVG
coordinates must use a dot as the decimal separator whatever the UI language is.
{% endcomment %}
{% localize off %}
<div class="concept-elevation" data-face="{{ elevation.face }}">
<h5 class="text-center text-muted mb-2">{{ elevation.face_label|title }}</h5>
<svg width="{{ elevation.width }}" height="{{ elevation.height }}"
viewBox="0 0 {{ elevation.width }} {{ elevation.height }}"
class="concept-elevation-svg" role="img"
aria-label="{% blocktrans with face=elevation.face_label name=elevation.concept.name %}{{ face }} elevation of {{ name }}{% endblocktrans %}">
{# Unit rows and legend #}
{% for unit in elevation.units %}
<g class="unit">
<rect x="{{ elevation.legend_width }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot"
data-unit="{{ unit.number }}"></rect>
<text x="{{ elevation.legend_width }}" y="{{ unit.y }}" dx="-4" dy="15"
class="unit-label" text-anchor="end">{{ unit.number }}</text>
</g>
{% endfor %}
{# Devices #}
{% for block in elevation.blocks %}
<g class="device-block {% if block.opposite %}opposite{% endif %}"
data-device-id="{{ block.device.pk }}"
data-position="{{ block.device.position }}"
data-u-height="{{ block.device.effective_u_height }}"
data-width-fraction="{{ block.device.width_fraction }}">
<a href="{{ block.device.get_absolute_url }}">
<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>
{% 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 %}
<title>{{ block.label }}{% if block.sublabel %} &middot; {{ block.sublabel }}{% endif %} &middot; U{{ block.device.position }}</title>
</a>
</g>
{% endfor %}
<rect x="{{ elevation.legend_width }}" y="6"
width="{{ elevation.rack_width }}" height="{{ elevation.height|add:'-12' }}"
class="rack-frame" fill="none"></rect>
</svg>
</div>
{% endlocalize %}
@@ -0,0 +1,16 @@
{% load i18n %}
{% load helpers %}
<div class="card">
<h2 class="card-header">{% trans "Rack Concepts" %}</h2>
<table class="table table-hover attr-table">
{% for concept in concepts %}
<tr>
<td>{{ concept|linkify }}</td>
<td class="text-end">{% badge concept.get_status_display bg_color=concept.get_status_color %}</td>
</tr>
{% endfor %}
</table>
<div class="card-footer small text-muted">
{% trans "Concepts copied from this rack. They are planning objects and do not affect this rack." %}
</div>
</div>
@@ -0,0 +1,4 @@
{% load i18n %}
<a href="{% url 'plugins:netbox_rack_concept:rack_copy' pk=rack.pk %}" class="btn btn-outline-secondary">
<i class="mdi mdi-view-grid-plus-outline"></i> {% trans "Copy to concept" %}
</a>
@@ -0,0 +1,35 @@
{% extends 'base/layout.html' %}
{% load i18n %}
{% load form_helpers %}
{% block title %}{% blocktrans with rack=rack.name %}Copy {{ rack }} to a concept{% endblocktrans %}{% 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">
{% blocktrans with rack=rack.name %}Copy rack {{ rack }} to a concept{% endblocktrans %}
</h2>
<div class="card-body">
<p class="text-muted">
{% blocktrans count counter=device_count %}
The rack contains {{ counter }} racked device, which can be snapshotted into the concept.
{% plural %}
The rack contains {{ counter }} racked devices, which can be snapshotted into the concept.
{% endblocktrans %}
{% trans "Nothing in the rack is modified: the concept is a separate object that does not appear in the rack or device lists." %}
</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 %}
@@ -0,0 +1,168 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load static %}
{% block head %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'netbox_rack_concept/elevation.css' %}">
{% endblock %}
{% block extra_controls %}
{{ block.super }}
{% if perms.netbox_rack_concept.add_rackconcept %}
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_clone_concept' pk=object.pk %}" class="btn btn-outline-secondary">
<i class="mdi mdi-content-duplicate"></i> {% trans "Clone concept" %}
</a>
{% endif %}
{% if perms.dcim.add_rack %}
<a href="{% url 'plugins:netbox_rack_concept:rackconcept_deploy' pk=object.pk %}" class="btn btn-primary">
<i class="mdi mdi-rocket-launch-outline"></i> {% trans "Deploy to NetBox" %}
</a>
{% endif %}
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Rack Concept" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant group" %}</th>
<td>{{ object.tenant_group|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>{{ object.tenant|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Copied from" %}</th>
<td>{{ object.source_rack|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Planned devices" %}</th>
<td>{{ object.devices.count }}</td>
</tr>
<tr>
<th scope="row">{% trans "Space utilization" %}</th>
<td>{% utilization_graph object.utilization %}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Geometry" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Rack type" %}</th>
<td>{{ object.rack_type|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Form factor" %}</th>
<td>{{ object.get_form_factor_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Width" %}</th>
<td>{{ object.get_width_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Height" %}</th>
<td>{{ object.u_height }}U</td>
</tr>
<tr>
<th scope="row">{% trans "Starting unit" %}</th>
<td>U{{ object.starting_unit }}</td>
</tr>
<tr>
<th scope="row">{% trans "Descending units" %}</th>
<td>{% checkmark object.desc_units %}</td>
</tr>
<tr>
<th scope="row">{% trans "Airflow" %}</th>
<td>{{ object.get_airflow_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Mounting depth" %}</th>
<td>{{ object.mounting_depth|placeholder }}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Deployment defaults" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>{{ object.default_site|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Location" %}</th>
<td>{{ object.default_location|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Rack role" %}</th>
<td>{{ object.rack_role|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/comments.html' %}
</div>
<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-body">
<div class="d-flex justify-content-around flex-wrap"
id="concept-elevations"
data-reorder-url="{% url 'plugins:netbox_rack_concept:rackconcept_reorder' pk=object.pk %}"
data-editable="{% if perms.netbox_rack_concept.change_conceptdevice %}true{% else %}false{% endif %}"
data-starting-unit="{{ object.starting_unit }}"
data-u-height="{{ object.u_height }}"
data-desc-units="{{ object.desc_units|yesno:'true,false' }}">
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=front_elevation %}
{% include 'netbox_rack_concept/inc/elevation.html' with elevation=rear_elevation %}
</div>
<div id="concept-elevation-status" class="small text-muted mt-2"></div>
</div>
{% if perms.netbox_rack_concept.add_conceptdevice %}
<div class="card-footer text-end">
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ object.pk }}&return_url={{ object.get_absolute_url }}"
class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick"></i> {% trans "Add planned device" %}
</a>
</div>
{% endif %}
</div>
<div class="card">
<h2 class="card-header">{% trans "Deployments" %}</h2>
{% render_table deployments_table %}
</div>
</div>
</div>
{% endblock %}
{% block javascript %}
{{ block.super }}
<script src="{% static 'netbox_rack_concept/elevation.js' %}"></script>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends 'generic/object_children.html' %}
{% load i18n %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.netbox_rack_concept.add_conceptdevice %}
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ object.pk }}&return_url={{ request.path }}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick"></i> {% trans "Add planned device" %}
</a>
{% endif %}
{% endblock %}