Mirror device cabling 1:1 (per-type tabs) and copy cables on rack import
- Split the device detail page's single "Components" table into the same seven tabs a real dcim.Device has, in the same order: Console Ports, Console Server Ports, Power Ports, Power Outlets, Interfaces, Front Ports, Rear Ports. Each tab is its own ObjectChildrenView filtered by component_type (one ConceptComponent model backs all of them, so this only needed the view/URL layer, not a schema change), hidden when empty, exactly like core NetBox. Replace the ad hoc "Add component" button with the same "Add Components" dropdown real device pages use, one entry per type, each landing back on its own tab. - When copying a real rack into a concept (or merging two real racks into one), automatically sync every copied device's components and recreate, as ConceptCables, every real cable whose both ends land on one of those devices - reading the same CableTermination data dcim itself uses, so the copy comes out cabled internally the same as the source. Cables reaching outside the copied rack(s), or multi- termination trunk cables, are skipped and reported as a count in the success message - the other end isn't part of the concept, so it can't be represented. copy_rack_to_concept() and merge_racks_to_concept() now return an extra cable_summary value; updated all their callers (views and API). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,11 +35,15 @@ Dazu:
|
|||||||
* **Klick auf eine leere HE** öffnet direkt „Add planned device", vorausgefüllt mit
|
* **Klick auf eine leere HE** öffnet direkt „Add planned device", vorausgefüllt mit
|
||||||
Position und Seite — genau wie bei einer echten Rack-Elevation. Den separaten
|
Position und Seite — genau wie bei einer echten Rack-Elevation. Den separaten
|
||||||
„Add planned device"-Knopf gibt es dafür nicht mehr.
|
„Add planned device"-Knopf gibt es dafür nicht mehr.
|
||||||
* **Verkabelung von Konzept-Geräten** – Interfaces, Front-/Rear-Ports, Power-Ports/
|
* **Verkabelung von Konzept-Geräten, 1:1 wie bei echten Geräten** – auf der
|
||||||
-Outlets und Console-Ports lassen sich mit „Sync components" aus dem Gerätetyp
|
Geräte-Detailseite gibt es dieselben Tabs wie bei einem echten `dcim.Device`:
|
||||||
übernehmen (inkl. Front-↔Rear-Zuordnung von Patchpanels, 1:1 wie beim echten
|
Interfaces, Front Ports, Rear Ports, Console Ports, Console Server Ports, Power
|
||||||
Gerätetyp) und über „Connect" mit einem Concept Cable verbinden. Platzhalter ohne
|
Ports, Power Outlets (gleiche Namen, gleiche Reihenfolge, Tab nur sichtbar wenn
|
||||||
Gerätetyp bekommen ihre Komponenten per Hand über „Add component".
|
belegt). „Sync components" übernimmt sie 1:1 aus den Component-Templates des
|
||||||
|
Gerätetyps, inkl. Front-↔Rear-Zuordnung von Patchpanels. Über „Connect" an jeder
|
||||||
|
unverkabelten Komponente wird sie mit einem Concept Cable verbunden. Ein „Add
|
||||||
|
Components"-Dropdown (genau wie am echten Gerät) legt neue Komponenten pro Typ an —
|
||||||
|
nötig für Platzhalter ohne Gerätetyp, die nichts zum Syncen haben.
|
||||||
* Vollständige REST-API, Filter, CSV-Import/-Export, Bulk-Edit, Changelog, Journal,
|
* Vollständige REST-API, Filter, CSV-Import/-Export, Bulk-Edit, Changelog, Journal,
|
||||||
Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt.
|
Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt.
|
||||||
|
|
||||||
@@ -67,6 +71,18 @@ daran, ob der Gerätetyp Front-/Rear-Ports mit Zuordnung definiert — Kabel an
|
|||||||
Vorderseite eines synchronisierten Patchpanel-Gerätetyps zeigen auf der Detailseite der
|
Vorderseite eines synchronisierten Patchpanel-Gerätetyps zeigen auf der Detailseite der
|
||||||
Komponente sofort den passenden Rear-Port samt Position an.
|
Komponente sofort den passenden Rear-Port samt Position an.
|
||||||
|
|
||||||
|
**Beim Kopieren eines echten Racks in ein Konzept** (und beim Zusammenführen zweier
|
||||||
|
echter Racks, siehe unten) werden die Komponenten aller übernommenen Geräte automatisch
|
||||||
|
synchronisiert, und jedes echte Kabel, dessen **beide** Enden innerhalb der kopierten
|
||||||
|
Rack(s) liegen, wird als `ConceptCable` nachgebaut — Status, Typ, Farbe und Label
|
||||||
|
inklusive. Kabel, die den Rack verlassen (zum Beispiel zum Uplink-Switch in einem
|
||||||
|
anderen Rack), lassen sich naturgemäß nicht übernehmen, weil das Gegenstück nicht Teil
|
||||||
|
des Konzepts ist; ebenso ausgelassen werden Mehrfach-Trunk-Kabel (z.B. MPO), da
|
||||||
|
`ConceptCable` nur Punkt-zu-Punkt-Verbindungen kennt. Beides wird nach dem Kopieren als
|
||||||
|
Warnung mit Anzahl gemeldet. Das Nachbilden der Verkabelung gilt nur für den Import
|
||||||
|
**echter** Racks — beim Klonen oder Zusammenführen von Konzepten (die ja schon eigene
|
||||||
|
Kabel haben können) bleibt die Verkabelung unverändert, wie bisher.
|
||||||
|
|
||||||
## Zusammenspiel mit den anderen Plugins
|
## Zusammenspiel mit den anderen Plugins
|
||||||
|
|
||||||
**netbox-reorder-rack (rackorder)** arbeitet auf `dcim.Device`-Querysets. Ein Konzept hat
|
**netbox-reorder-rack (rackorder)** arbeitet auf `dcim.Device`-Querysets. Ein Konzept hat
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ __version__ = '0.1.0'
|
|||||||
|
|
||||||
class RackConceptConfig(PluginConfig):
|
class RackConceptConfig(PluginConfig):
|
||||||
name = 'netbox_rack_concept'
|
name = 'netbox_rack_concept'
|
||||||
verbose_name = 'Rack Concepts'
|
verbose_name = 'NetBox Rack Concepts'
|
||||||
description = 'Rack blueprints: plan racks and their equipment without creating real racks or devices'
|
description = 'Rack blueprints: plan racks and their equipment without creating real racks or devices'
|
||||||
version = __version__
|
version = __version__
|
||||||
author = 'Louis'
|
author = 'Claude'
|
||||||
base_url = 'rack-concepts'
|
base_url = 'rack-concepts'
|
||||||
min_version = '4.6.0'
|
min_version = '4.6.0'
|
||||||
max_version = '4.6.99'
|
max_version = '4.6.99'
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class RackConceptViewSet(NetBoxModelViewSet):
|
|||||||
tenant = data.get('tenant')
|
tenant = data.get('tenant')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
concept, warnings = deploy_utils.copy_rack_to_concept(
|
concept, warnings, cable_summary = deploy_utils.copy_rack_to_concept(
|
||||||
rack=rack,
|
rack=rack,
|
||||||
name=data['name'],
|
name=data['name'],
|
||||||
status=data['status'],
|
status=data['status'],
|
||||||
@@ -60,6 +60,7 @@ class RackConceptViewSet(NetBoxModelViewSet):
|
|||||||
|
|
||||||
result = RackConceptSerializer(concept, context={'request': request}).data
|
result = RackConceptSerializer(concept, context={'request': request}).data
|
||||||
result['warnings'] = warnings
|
result['warnings'] = warnings
|
||||||
|
result['cables'] = cable_summary
|
||||||
return Response(result, status=status.HTTP_201_CREATED)
|
return Response(result, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
|
|||||||
@@ -9,12 +9,26 @@ that's what its device type's templates already describe - the same as a real de
|
|||||||
Placeholder devices without a device type have no templates to sync from; their
|
Placeholder devices without a device type have no templates to sync from; their
|
||||||
components, if any, have to be added by hand.
|
components, if any, have to be added by hand.
|
||||||
"""
|
"""
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from .choices import ConceptComponentTypeChoices
|
from dcim.models import Cable, ConsolePort, ConsoleServerPort, FrontPort, Interface, PowerOutlet, PowerPort, RearPort
|
||||||
from .models import ConceptComponent, ConceptPortMapping
|
|
||||||
|
|
||||||
__all__ = ('sync_components',)
|
from .choices import ConceptComponentTypeChoices
|
||||||
|
from .models import ConceptCable, ConceptComponent, ConceptPortMapping
|
||||||
|
|
||||||
|
__all__ = ('copy_cables', 'sync_components')
|
||||||
|
|
||||||
|
# (our component type, the real dcim model that can terminate a real cable)
|
||||||
|
CABLED_MODELS = (
|
||||||
|
(ConceptComponentTypeChoices.TYPE_INTERFACE, Interface),
|
||||||
|
(ConceptComponentTypeChoices.TYPE_CONSOLE_PORT, ConsolePort),
|
||||||
|
(ConceptComponentTypeChoices.TYPE_CONSOLE_SERVER_PORT, ConsoleServerPort),
|
||||||
|
(ConceptComponentTypeChoices.TYPE_POWER_PORT, PowerPort),
|
||||||
|
(ConceptComponentTypeChoices.TYPE_POWER_OUTLET, PowerOutlet),
|
||||||
|
(ConceptComponentTypeChoices.TYPE_FRONT_PORT, FrontPort),
|
||||||
|
(ConceptComponentTypeChoices.TYPE_REAR_PORT, RearPort),
|
||||||
|
)
|
||||||
|
|
||||||
# (our component type, DeviceType's reverse accessor for that template queryset)
|
# (our component type, DeviceType's reverse accessor for that template queryset)
|
||||||
TEMPLATE_SOURCES = (
|
TEMPLATE_SOURCES = (
|
||||||
@@ -77,3 +91,73 @@ def sync_components(concept_device):
|
|||||||
mappings_created += 1
|
mappings_created += 1
|
||||||
|
|
||||||
return created, mappings_created
|
return created, mappings_created
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def copy_cables(concept_devices):
|
||||||
|
"""
|
||||||
|
Recreate, as ConceptCables, every real cable whose both ends sit on one of the
|
||||||
|
given concept devices' source devices (i.e. cables internal to whatever rack(s)
|
||||||
|
were just imported). A cable reaching a device outside that set can't be
|
||||||
|
represented here - the other end isn't part of the concept - and is skipped, along
|
||||||
|
with any multi-termination trunk cable, which ConceptCable's plain point-to-point
|
||||||
|
model can't hold. Only devices whose components have already been synced (see
|
||||||
|
sync_components) can have their cables copied.
|
||||||
|
Returns (copied, skipped).
|
||||||
|
"""
|
||||||
|
device_map = {cd.source_device_id: cd for cd in concept_devices if cd.source_device_id}
|
||||||
|
if not device_map:
|
||||||
|
return 0, 0
|
||||||
|
device_ids = set(device_map)
|
||||||
|
|
||||||
|
component_lookup = {}
|
||||||
|
for component in ConceptComponent.objects.filter(device__in=device_map.values()).select_related('device'):
|
||||||
|
component_lookup[(component.device.source_device_id, component.name)] = component
|
||||||
|
|
||||||
|
cable_ids = set()
|
||||||
|
for _, model in CABLED_MODELS:
|
||||||
|
cable_ids.update(
|
||||||
|
model.objects.filter(device_id__in=device_ids, cable_id__isnull=False)
|
||||||
|
.values_list('cable_id', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
copied = skipped = 0
|
||||||
|
for cable in Cable.objects.filter(pk__in=cable_ids).prefetch_related('terminations'):
|
||||||
|
a_ends = [ct.termination for ct in cable.terminations.all() if ct.cable_end == 'A']
|
||||||
|
b_ends = [ct.termination for ct in cable.terminations.all() if ct.cable_end == 'B']
|
||||||
|
if len(a_ends) != 1 or len(b_ends) != 1:
|
||||||
|
# A multi-termination trunk cable - not something a point-to-point
|
||||||
|
# ConceptCable can represent.
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
a, b = a_ends[0], b_ends[0]
|
||||||
|
a_device_id, b_device_id = getattr(a, 'device_id', None), getattr(b, 'device_id', None)
|
||||||
|
if a_device_id not in device_ids or b_device_id not in device_ids:
|
||||||
|
# Reaches a device outside the set being copied.
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
component_a = component_lookup.get((a_device_id, a.name))
|
||||||
|
component_b = component_lookup.get((b_device_id, b.name))
|
||||||
|
if not (component_a and component_b):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
concept_cable = ConceptCable(
|
||||||
|
termination_a=component_a,
|
||||||
|
termination_b=component_b,
|
||||||
|
status=cable.status,
|
||||||
|
type=cable.type or '',
|
||||||
|
color=cable.color or '',
|
||||||
|
label=cable.label,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
concept_cable.full_clean()
|
||||||
|
except ValidationError:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
concept_cable.save()
|
||||||
|
copied += 1
|
||||||
|
|
||||||
|
return copied, skipped
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from django.utils.translation import gettext as _
|
|||||||
from dcim.choices import RackWidthChoices
|
from dcim.choices import RackWidthChoices
|
||||||
from dcim.models import Device, Rack
|
from dcim.models import Device, Rack
|
||||||
|
|
||||||
|
from . import components
|
||||||
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices
|
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices
|
||||||
from .integrations import apply_partial_width, read_partial_width
|
from .integrations import apply_partial_width, read_partial_width
|
||||||
from .models import ConceptDeployment, ConceptDevice, RackConcept
|
from .models import ConceptDeployment, ConceptDevice, RackConcept
|
||||||
@@ -54,7 +55,12 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t
|
|||||||
override lets the concept end up a different size than the source rack (e.g. to
|
override lets the concept end up a different size than the source rack (e.g. to
|
||||||
plan "what if we had a taller one"); devices that no longer fit are skipped and
|
plan "what if we had a taller one"); devices that no longer fit are skipped and
|
||||||
reported back as warnings rather than aborting the whole copy.
|
reported back as warnings rather than aborting the whole copy.
|
||||||
Returns (concept, warnings).
|
|
||||||
|
Copied devices also get their components synced from their device type, and any
|
||||||
|
cable between two of them is recreated as a ConceptCable - so the copy is cabled
|
||||||
|
exactly like the source rack wherever both ends of a cable stayed inside it.
|
||||||
|
Returns (concept, warnings, cable_summary), where cable_summary is
|
||||||
|
{'copied': int, 'skipped': int}.
|
||||||
"""
|
"""
|
||||||
concept = RackConcept(
|
concept = RackConcept(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -90,10 +96,12 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t
|
|||||||
concept.save()
|
concept.save()
|
||||||
|
|
||||||
warnings = []
|
warnings = []
|
||||||
|
cable_summary = {'copied': 0, 'skipped': 0}
|
||||||
if include_devices:
|
if include_devices:
|
||||||
devices = Device.objects.filter(rack=rack, position__isnull=False).select_related(
|
devices = Device.objects.filter(rack=rack, position__isnull=False).select_related(
|
||||||
'device_type', 'role', 'platform', 'tenant'
|
'device_type', 'role', 'platform', 'tenant'
|
||||||
)
|
)
|
||||||
|
new_devices = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
fraction, horizontal = read_partial_width(device)
|
fraction, horizontal = read_partial_width(device)
|
||||||
concept_device = ConceptDevice(
|
concept_device = ConceptDevice(
|
||||||
@@ -120,8 +128,15 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t
|
|||||||
) % {'device': device, 'position': device.position})
|
) % {'device': device, 'position': device.position})
|
||||||
continue
|
continue
|
||||||
concept_device.save()
|
concept_device.save()
|
||||||
|
new_devices.append(concept_device)
|
||||||
|
|
||||||
return concept, warnings
|
# Recreate each device's components from its device type, then wire up
|
||||||
|
# whichever cables stayed entirely inside the rack being copied.
|
||||||
|
for concept_device in new_devices:
|
||||||
|
components.sync_components(concept_device)
|
||||||
|
cable_summary['copied'], cable_summary['skipped'] = components.copy_cables(new_devices)
|
||||||
|
|
||||||
|
return concept, warnings, cable_summary
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -209,7 +224,12 @@ def merge_racks_to_concept(rack_bottom, rack_top, name, status=ConceptStatusChoi
|
|||||||
Combine two real racks directly into one new, taller blueprint - e.g. two
|
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
|
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
|
position; rack_top's are shifted up by rack_bottom's height. Neither source rack is
|
||||||
modified. Returns (concept, warnings).
|
modified.
|
||||||
|
|
||||||
|
Copied devices also get their components synced from their device type, and any
|
||||||
|
cable between two of them (from either rack) is recreated as a ConceptCable.
|
||||||
|
Returns (concept, warnings, cable_summary), where cable_summary is
|
||||||
|
{'copied': int, 'skipped': int}.
|
||||||
"""
|
"""
|
||||||
bottom_height = _rack_geometry(rack_bottom, 'u_height') or rack_bottom.u_height
|
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
|
top_height = _rack_geometry(rack_top, 'u_height') or rack_top.u_height
|
||||||
@@ -235,6 +255,7 @@ def merge_racks_to_concept(rack_bottom, rack_top, name, status=ConceptStatusChoi
|
|||||||
concept.save()
|
concept.save()
|
||||||
|
|
||||||
warnings = []
|
warnings = []
|
||||||
|
new_devices = []
|
||||||
|
|
||||||
def _copy_from_rack(rack, shift):
|
def _copy_from_rack(rack, shift):
|
||||||
if not include_devices:
|
if not include_devices:
|
||||||
@@ -267,11 +288,17 @@ def merge_racks_to_concept(rack_bottom, rack_top, name, status=ConceptStatusChoi
|
|||||||
) % {'device': device, 'rack': rack})
|
) % {'device': device, 'rack': rack})
|
||||||
continue
|
continue
|
||||||
concept_device.save()
|
concept_device.save()
|
||||||
|
new_devices.append(concept_device)
|
||||||
|
|
||||||
_copy_from_rack(rack_bottom, 0)
|
_copy_from_rack(rack_bottom, 0)
|
||||||
_copy_from_rack(rack_top, bottom_height)
|
_copy_from_rack(rack_top, bottom_height)
|
||||||
|
|
||||||
return concept, warnings
|
for concept_device in new_devices:
|
||||||
|
components.sync_components(concept_device)
|
||||||
|
cable_summary = {'copied': 0, 'skipped': 0}
|
||||||
|
cable_summary['copied'], cable_summary['skipped'] = components.copy_cables(new_devices)
|
||||||
|
|
||||||
|
return concept, warnings, cable_summary
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{% extends 'generic/object.html' %}
|
{% extends 'generic/object.html' %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load render_table from django_tables2 %}
|
|
||||||
|
|
||||||
{% block extra_controls %}
|
{% block extra_controls %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
@@ -10,6 +9,22 @@
|
|||||||
<i class="mdi mdi-sync"></i> {% trans "Sync components" %}
|
<i class="mdi mdi-sync"></i> {% trans "Sync components" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if perms.netbox_rack_concept.add_conceptcomponent %}
|
||||||
|
<div class="dropdown">
|
||||||
|
<button id="add-components" type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="add-components">
|
||||||
|
<li><a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:conceptcomponent_add' %}?device={{ object.pk }}&component_type=console-port&return_url={% url 'plugins:netbox_rack_concept:conceptdevice_consoleports' pk=object.pk %}">{% trans "Console Ports" %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:conceptcomponent_add' %}?device={{ object.pk }}&component_type=console-server-port&return_url={% url 'plugins:netbox_rack_concept:conceptdevice_consoleserverports' pk=object.pk %}">{% trans "Console Server Ports" %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:conceptcomponent_add' %}?device={{ object.pk }}&component_type=power-port&return_url={% url 'plugins:netbox_rack_concept:conceptdevice_powerports' pk=object.pk %}">{% trans "Power Ports" %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:conceptcomponent_add' %}?device={{ object.pk }}&component_type=power-outlet&return_url={% url 'plugins:netbox_rack_concept:conceptdevice_poweroutlets' pk=object.pk %}">{% trans "Power Outlets" %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:conceptcomponent_add' %}?device={{ object.pk }}&component_type=interface&return_url={% url 'plugins:netbox_rack_concept:conceptdevice_interfaces' pk=object.pk %}">{% trans "Interfaces" %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:conceptcomponent_add' %}?device={{ object.pk }}&component_type=front-port&return_url={% url 'plugins:netbox_rack_concept:conceptdevice_frontports' pk=object.pk %}">{% trans "Front Ports" %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:conceptcomponent_add' %}?device={{ object.pk }}&component_type=rear-port&return_url={% url 'plugins:netbox_rack_concept:conceptdevice_rearports' pk=object.pk %}">{% trans "Rear Ports" %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -109,21 +124,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col col-md-12">
|
|
||||||
<div class="card">
|
|
||||||
<h2 class="card-header">
|
|
||||||
{% trans "Components" %}
|
|
||||||
{% if perms.netbox_rack_concept.add_conceptcomponent %}
|
|
||||||
<a href="{% url 'plugins:netbox_rack_concept:conceptcomponent_add' %}?device={{ object.pk }}"
|
|
||||||
class="btn btn-sm btn-primary float-end">
|
|
||||||
<i class="mdi mdi-plus-thick"></i> {% trans "Add component" %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</h2>
|
|
||||||
{% render_table component_table %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends 'generic/object_children.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block bulk_extra_controls %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% if perms.netbox_rack_concept.add_conceptcomponent %}
|
||||||
|
<a href="{% url 'plugins:netbox_rack_concept:conceptcomponent_add' %}?device={{ object.pk }}&component_type={{ component_type }}&return_url={{ request.path }}"
|
||||||
|
class="btn btn-primary">
|
||||||
|
<i class="mdi mdi-plus-thick"></i> {% blocktrans %}Add {{ component_type_label }}{% endblocktrans %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
+146
-17
@@ -17,6 +17,7 @@ from netbox.views import generic
|
|||||||
from utilities.views import ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
from utilities.views import ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
||||||
|
|
||||||
from . import components, deploy, filtersets, forms, tables
|
from . import components, deploy, filtersets, forms, tables
|
||||||
|
from .choices import ConceptComponentTypeChoices
|
||||||
from .elevation import get_elevations
|
from .elevation import get_elevations
|
||||||
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
|
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
|
||||||
|
|
||||||
@@ -140,17 +141,6 @@ class ConceptDeviceView(generic.ObjectView):
|
|||||||
)
|
)
|
||||||
template_name = 'netbox_rack_concept/conceptdevice.html'
|
template_name = 'netbox_rack_concept/conceptdevice.html'
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
|
||||||
component_table = tables.ConceptComponentTable(
|
|
||||||
instance.components.select_related('device').order_by('component_type', 'name'),
|
|
||||||
exclude=('device', 'concept'),
|
|
||||||
orderable=False,
|
|
||||||
)
|
|
||||||
component_table.configure(request)
|
|
||||||
return {
|
|
||||||
'component_table': component_table,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ConceptDevice, 'sync_components')
|
@register_model_view(ConceptDevice, 'sync_components')
|
||||||
class ConceptDeviceSyncComponentsView(ObjectPermissionRequiredMixin, View):
|
class ConceptDeviceSyncComponentsView(ObjectPermissionRequiredMixin, View):
|
||||||
@@ -185,6 +175,129 @@ class ConceptDeviceSyncComponentsView(ObjectPermissionRequiredMixin, View):
|
|||||||
return redirect(device.get_absolute_url())
|
return redirect(device.get_absolute_url())
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptDeviceComponentsView(generic.ObjectChildrenView):
|
||||||
|
"""
|
||||||
|
Base for the per-type component tabs (Interfaces / Front Ports / ...), mirroring
|
||||||
|
dcim's own device component tabs one-for-one: same names, paths and tab order.
|
||||||
|
Since one ConceptComponent model covers every type (see models.py), a subclass only
|
||||||
|
needs to say which component_type it shows - there's no separate model per tab to
|
||||||
|
plug in the way dcim.Interface/dcim.FrontPort/etc. do.
|
||||||
|
"""
|
||||||
|
queryset = ConceptDevice.objects.all()
|
||||||
|
child_model = ConceptComponent
|
||||||
|
table = tables.ConceptComponentTable
|
||||||
|
filterset = filtersets.ConceptComponentFilterSet
|
||||||
|
filterset_form = forms.ConceptComponentFilterForm
|
||||||
|
template_name = 'netbox_rack_concept/conceptdevice_components.html'
|
||||||
|
component_type = None
|
||||||
|
|
||||||
|
def get_children(self, request, parent):
|
||||||
|
return ConceptComponent.objects.restrict(request.user, 'view').filter(
|
||||||
|
device=parent, component_type=self.component_type
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_table(self, data, request, bulk_actions=True):
|
||||||
|
# device/concept/component_type are all implied by "which tab this is" - showing
|
||||||
|
# them again on every row would just be noise.
|
||||||
|
table = self.table(data, exclude=('device', 'concept', 'component_type'))
|
||||||
|
if 'pk' in table.base_columns and bulk_actions:
|
||||||
|
table.columns.show('pk')
|
||||||
|
table.configure(request)
|
||||||
|
return table
|
||||||
|
|
||||||
|
def get_extra_context(self, request, instance):
|
||||||
|
return {
|
||||||
|
'component_type': self.component_type,
|
||||||
|
'component_type_label': self.tab.label,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptDevice, 'consoleports', path='console-ports')
|
||||||
|
class ConceptDeviceConsolePortsView(ConceptDeviceComponentsView):
|
||||||
|
component_type = ConceptComponentTypeChoices.TYPE_CONSOLE_PORT
|
||||||
|
tab = ViewTab(
|
||||||
|
label=_('Console Ports'),
|
||||||
|
badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_CONSOLE_PORT).count(),
|
||||||
|
permission='netbox_rack_concept.view_conceptcomponent',
|
||||||
|
weight=550,
|
||||||
|
hide_if_empty=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptDevice, 'consoleserverports', path='console-server-ports')
|
||||||
|
class ConceptDeviceConsoleServerPortsView(ConceptDeviceComponentsView):
|
||||||
|
component_type = ConceptComponentTypeChoices.TYPE_CONSOLE_SERVER_PORT
|
||||||
|
tab = ViewTab(
|
||||||
|
label=_('Console Server Ports'),
|
||||||
|
badge=lambda obj: obj.components.filter(
|
||||||
|
component_type=ConceptComponentTypeChoices.TYPE_CONSOLE_SERVER_PORT
|
||||||
|
).count(),
|
||||||
|
permission='netbox_rack_concept.view_conceptcomponent',
|
||||||
|
weight=560,
|
||||||
|
hide_if_empty=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptDevice, 'powerports', path='power-ports')
|
||||||
|
class ConceptDevicePowerPortsView(ConceptDeviceComponentsView):
|
||||||
|
component_type = ConceptComponentTypeChoices.TYPE_POWER_PORT
|
||||||
|
tab = ViewTab(
|
||||||
|
label=_('Power Ports'),
|
||||||
|
badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_POWER_PORT).count(),
|
||||||
|
permission='netbox_rack_concept.view_conceptcomponent',
|
||||||
|
weight=570,
|
||||||
|
hide_if_empty=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptDevice, 'poweroutlets', path='power-outlets')
|
||||||
|
class ConceptDevicePowerOutletsView(ConceptDeviceComponentsView):
|
||||||
|
component_type = ConceptComponentTypeChoices.TYPE_POWER_OUTLET
|
||||||
|
tab = ViewTab(
|
||||||
|
label=_('Power Outlets'),
|
||||||
|
badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_POWER_OUTLET).count(),
|
||||||
|
permission='netbox_rack_concept.view_conceptcomponent',
|
||||||
|
weight=580,
|
||||||
|
hide_if_empty=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptDevice, 'interfaces')
|
||||||
|
class ConceptDeviceInterfacesView(ConceptDeviceComponentsView):
|
||||||
|
component_type = ConceptComponentTypeChoices.TYPE_INTERFACE
|
||||||
|
tab = ViewTab(
|
||||||
|
label=_('Interfaces'),
|
||||||
|
badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_INTERFACE).count(),
|
||||||
|
permission='netbox_rack_concept.view_conceptcomponent',
|
||||||
|
weight=520,
|
||||||
|
hide_if_empty=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptDevice, 'frontports', path='front-ports')
|
||||||
|
class ConceptDeviceFrontPortsView(ConceptDeviceComponentsView):
|
||||||
|
component_type = ConceptComponentTypeChoices.TYPE_FRONT_PORT
|
||||||
|
tab = ViewTab(
|
||||||
|
label=_('Front Ports'),
|
||||||
|
badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_FRONT_PORT).count(),
|
||||||
|
permission='netbox_rack_concept.view_conceptcomponent',
|
||||||
|
weight=530,
|
||||||
|
hide_if_empty=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(ConceptDevice, 'rearports', path='rear-ports')
|
||||||
|
class ConceptDeviceRearPortsView(ConceptDeviceComponentsView):
|
||||||
|
component_type = ConceptComponentTypeChoices.TYPE_REAR_PORT
|
||||||
|
tab = ViewTab(
|
||||||
|
label=_('Rear Ports'),
|
||||||
|
badge=lambda obj: obj.components.filter(component_type=ConceptComponentTypeChoices.TYPE_REAR_PORT).count(),
|
||||||
|
permission='netbox_rack_concept.view_conceptcomponent',
|
||||||
|
weight=540,
|
||||||
|
hide_if_empty=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ConceptDevice, 'add', detail=False)
|
@register_model_view(ConceptDevice, 'add', detail=False)
|
||||||
@register_model_view(ConceptDevice, 'edit')
|
@register_model_view(ConceptDevice, 'edit')
|
||||||
class ConceptDeviceEditView(generic.ObjectEditView):
|
class ConceptDeviceEditView(generic.ObjectEditView):
|
||||||
@@ -249,7 +362,7 @@ class RackCopyView(ObjectPermissionRequiredMixin, View):
|
|||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
try:
|
try:
|
||||||
concept, warnings = deploy.copy_rack_to_concept(
|
concept, warnings, cable_summary = deploy.copy_rack_to_concept(
|
||||||
rack=rack,
|
rack=rack,
|
||||||
name=form.cleaned_data['name'],
|
name=form.cleaned_data['name'],
|
||||||
status=form.cleaned_data['status'],
|
status=form.cleaned_data['status'],
|
||||||
@@ -267,9 +380,17 @@ class RackCopyView(ObjectPermissionRequiredMixin, View):
|
|||||||
else:
|
else:
|
||||||
for warning in warnings:
|
for warning in warnings:
|
||||||
messages.warning(request, warning)
|
messages.warning(request, warning)
|
||||||
messages.success(request, _('Created concept %(concept)s from rack %(rack)s.') % {
|
if cable_summary['skipped']:
|
||||||
|
messages.warning(request, _(
|
||||||
|
'%(count)s cable(s) could not be copied because they reach outside the rack or use more '
|
||||||
|
'than one termination per side.'
|
||||||
|
) % {'count': cable_summary['skipped']})
|
||||||
|
success_message = _('Created concept %(concept)s from rack %(rack)s.') % {
|
||||||
'concept': concept, 'rack': rack
|
'concept': concept, 'rack': rack
|
||||||
})
|
}
|
||||||
|
if cable_summary['copied']:
|
||||||
|
success_message += ' ' + _('Copied %(count)s cable(s).') % {'count': cable_summary['copied']}
|
||||||
|
messages.success(request, success_message)
|
||||||
return redirect(concept.get_absolute_url())
|
return redirect(concept.get_absolute_url())
|
||||||
|
|
||||||
return render(request, 'netbox_rack_concept/rack_copy.html', {
|
return render(request, 'netbox_rack_concept/rack_copy.html', {
|
||||||
@@ -354,7 +475,7 @@ class MergeRacksToConceptView(ObjectPermissionRequiredMixin, View):
|
|||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
try:
|
try:
|
||||||
concept, warnings = deploy.merge_racks_to_concept(
|
concept, warnings, cable_summary = deploy.merge_racks_to_concept(
|
||||||
rack_bottom=form.cleaned_data['rack_bottom'],
|
rack_bottom=form.cleaned_data['rack_bottom'],
|
||||||
rack_top=form.cleaned_data['rack_top'],
|
rack_top=form.cleaned_data['rack_top'],
|
||||||
name=form.cleaned_data['name'],
|
name=form.cleaned_data['name'],
|
||||||
@@ -373,11 +494,19 @@ class MergeRacksToConceptView(ObjectPermissionRequiredMixin, View):
|
|||||||
else:
|
else:
|
||||||
for warning in warnings:
|
for warning in warnings:
|
||||||
messages.warning(request, warning)
|
messages.warning(request, warning)
|
||||||
messages.success(request, _('Created concept %(concept)s from %(bottom)s and %(top)s.') % {
|
if cable_summary['skipped']:
|
||||||
|
messages.warning(request, _(
|
||||||
|
'%(count)s cable(s) could not be copied because they reach outside the two racks or use '
|
||||||
|
'more than one termination per side.'
|
||||||
|
) % {'count': cable_summary['skipped']})
|
||||||
|
success_message = _('Created concept %(concept)s from %(bottom)s and %(top)s.') % {
|
||||||
'concept': concept,
|
'concept': concept,
|
||||||
'bottom': form.cleaned_data['rack_bottom'],
|
'bottom': form.cleaned_data['rack_bottom'],
|
||||||
'top': form.cleaned_data['rack_top'],
|
'top': form.cleaned_data['rack_top'],
|
||||||
})
|
}
|
||||||
|
if cable_summary['copied']:
|
||||||
|
success_message += ' ' + _('Copied %(count)s cable(s).') % {'count': cable_summary['copied']}
|
||||||
|
messages.success(request, success_message)
|
||||||
return redirect(concept.get_absolute_url())
|
return redirect(concept.get_absolute_url())
|
||||||
|
|
||||||
return render(request, 'netbox_rack_concept/racks_merge.html', {
|
return render(request, 'netbox_rack_concept/racks_merge.html', {
|
||||||
|
|||||||
Reference in New Issue
Block a user