- 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>
470 lines
18 KiB
Python
470 lines
18 KiB
Python
"""
|
|
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 . import components
|
|
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices
|
|
from .integrations import apply_partial_width, read_partial_width
|
|
from .models import ConceptDeployment, ConceptDevice, RackConcept
|
|
|
|
__all__ = (
|
|
'clone_concept',
|
|
'copy_rack_to_concept',
|
|
'deploy_concept',
|
|
'merge_concepts',
|
|
'merge_racks_to_concept',
|
|
'validate_deployment',
|
|
)
|
|
|
|
GEOMETRY_FIELDS = (
|
|
'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
|
|
'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'max_weight',
|
|
)
|
|
|
|
|
|
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
|
|
#
|
|
|
|
@transaction.atomic
|
|
def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, tenant_group=None,
|
|
description='', include_devices=True, include_device_names=True,
|
|
override_width=None, override_u_height=None, override_starting_unit=None):
|
|
"""
|
|
Snapshot a real rack, and optionally its contents, into a new blueprint. Passing an
|
|
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
|
|
reported back as warnings rather than aborting the whole copy.
|
|
|
|
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(
|
|
name=name,
|
|
status=status,
|
|
tenant=tenant,
|
|
tenant_group=tenant_group,
|
|
description=description,
|
|
rack_type=rack.rack_type,
|
|
default_site=rack.site,
|
|
default_location=rack.location,
|
|
rack_role=rack.role,
|
|
source_rack=rack,
|
|
)
|
|
for field in GEOMETRY_FIELDS:
|
|
value = _rack_geometry(rack, field)
|
|
if value not in (None, ''):
|
|
setattr(concept, field, value)
|
|
|
|
has_override = any(v is not None for v in (override_width, override_u_height, override_starting_unit))
|
|
if has_override:
|
|
# The concept's own geometry fields are ignored on deploy as long as rack_type
|
|
# is set (NetBox derives geometry from the type). Since the override is the
|
|
# whole point here, it must not be silently overruled by the source's type.
|
|
concept.rack_type = None
|
|
if override_width:
|
|
concept.width = override_width
|
|
if override_u_height:
|
|
concept.u_height = override_u_height
|
|
if override_starting_unit:
|
|
concept.starting_unit = override_starting_unit
|
|
|
|
concept.full_clean()
|
|
concept.save()
|
|
|
|
warnings = []
|
|
cable_summary = {'copied': 0, 'skipped': 0}
|
|
if include_devices:
|
|
devices = Device.objects.filter(rack=rack, position__isnull=False).select_related(
|
|
'device_type', 'role', 'platform', 'tenant'
|
|
)
|
|
new_devices = []
|
|
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,
|
|
face=device.face,
|
|
width_fraction=fraction,
|
|
horizontal_position=horizontal,
|
|
serial=device.serial if include_device_names else '',
|
|
source_device=device,
|
|
)
|
|
if has_override:
|
|
try:
|
|
concept_device.full_clean()
|
|
except ValidationError:
|
|
warnings.append(_(
|
|
'%(device)s (U%(position)s) does not fit in the chosen size and was left out.'
|
|
) % {'device': device, 'position': device.position})
|
|
continue
|
|
concept_device.save()
|
|
new_devices.append(concept_device)
|
|
|
|
# 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
|
|
|
|
|
|
#
|
|
# RackConcept + RackConcept -> RackConcept (stacked)
|
|
#
|
|
|
|
@transaction.atomic
|
|
def merge_concepts(concept_bottom, concept_top, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None,
|
|
tenant_group=None, width=None, u_height=None, starting_unit=1, description=''):
|
|
"""
|
|
Stack two blueprints into one taller one - e.g. two half-height racks combined into
|
|
a full-height rack. concept_bottom's devices keep their position; concept_top's are
|
|
shifted up by concept_bottom's height. Neither source concept is modified.
|
|
Returns (merged_concept, warnings).
|
|
"""
|
|
offset = concept_bottom.u_height
|
|
total_height = u_height or (concept_bottom.u_height + concept_top.u_height)
|
|
|
|
merged = RackConcept(
|
|
name=name,
|
|
status=status,
|
|
tenant=tenant,
|
|
tenant_group=tenant_group,
|
|
description=description,
|
|
width=width or concept_bottom.width,
|
|
u_height=total_height,
|
|
starting_unit=starting_unit,
|
|
form_factor=concept_bottom.form_factor,
|
|
airflow=concept_bottom.airflow,
|
|
default_site=concept_bottom.default_site or concept_top.default_site,
|
|
default_location=concept_bottom.default_location or concept_top.default_location,
|
|
rack_role=concept_bottom.rack_role or concept_top.rack_role,
|
|
)
|
|
merged.full_clean()
|
|
merged.save()
|
|
|
|
warnings = []
|
|
|
|
def _copy_devices(source, shift):
|
|
for device in source.devices.all():
|
|
concept_device = ConceptDevice(
|
|
concept=merged,
|
|
name=device.name,
|
|
label=device.label,
|
|
status=device.status,
|
|
device_type=device.device_type,
|
|
role=device.role,
|
|
platform=device.platform,
|
|
tenant=device.tenant,
|
|
position=(device.position + shift) if device.position is not None else None,
|
|
face=device.face,
|
|
width_fraction=device.width_fraction,
|
|
horizontal_position=device.horizontal_position,
|
|
u_height=device.u_height,
|
|
is_full_depth=device.is_full_depth,
|
|
serial=device.serial,
|
|
description=device.description,
|
|
comments=device.comments,
|
|
source_device=device.source_device,
|
|
)
|
|
try:
|
|
concept_device.full_clean()
|
|
except ValidationError:
|
|
warnings.append(_(
|
|
'%(device)s from %(concept)s does not fit at its shifted position and was left out.'
|
|
) % {'device': device, 'concept': source})
|
|
continue
|
|
concept_device.save()
|
|
|
|
_copy_devices(concept_bottom, 0)
|
|
_copy_devices(concept_top, offset)
|
|
|
|
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.
|
|
|
|
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
|
|
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 = []
|
|
new_devices = []
|
|
|
|
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()
|
|
new_devices.append(concept_device)
|
|
|
|
_copy_from_rack(rack_bottom, 0)
|
|
_copy_from_rack(rack_top, bottom_height)
|
|
|
|
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
|
|
|
|
|
|
#
|
|
# RackConcept -> RackConcept
|
|
#
|
|
|
|
@transaction.atomic
|
|
def clone_concept(concept, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, include_devices=True):
|
|
"""Duplicate a blueprint so variants can be built without touching the original."""
|
|
clone = RackConcept(
|
|
name=name,
|
|
status=status,
|
|
tenant=tenant or concept.tenant,
|
|
tenant_group=concept.tenant_group,
|
|
description=concept.description,
|
|
comments=concept.comments,
|
|
rack_type=concept.rack_type,
|
|
default_site=concept.default_site,
|
|
default_location=concept.default_location,
|
|
rack_role=concept.rack_role,
|
|
source_rack=concept.source_rack,
|
|
)
|
|
for field in GEOMETRY_FIELDS:
|
|
setattr(clone, field, getattr(concept, field))
|
|
clone.full_clean()
|
|
clone.save()
|
|
|
|
if include_devices:
|
|
for device in concept.devices.all():
|
|
ConceptDevice.objects.create(
|
|
concept=clone,
|
|
name=device.name,
|
|
label=device.label,
|
|
status=device.status,
|
|
device_type=device.device_type,
|
|
role=device.role,
|
|
platform=device.platform,
|
|
tenant=device.tenant,
|
|
position=device.position,
|
|
face=device.face,
|
|
width_fraction=device.width_fraction,
|
|
horizontal_position=device.horizontal_position,
|
|
u_height=device.u_height,
|
|
is_full_depth=device.is_full_depth,
|
|
serial=device.serial,
|
|
description=device.description,
|
|
comments=device.comments,
|
|
source_device=device.source_device,
|
|
)
|
|
|
|
return clone
|
|
|
|
|
|
#
|
|
# RackConcept -> dcim.Rack
|
|
#
|
|
|
|
def validate_deployment(concept, create_devices=True, skip_unnamed_devices=True):
|
|
"""
|
|
Report what a deployment would and would not be able to create. Returned as
|
|
(deployable_devices, warnings) so the confirmation page can show it before anything
|
|
is written.
|
|
"""
|
|
warnings = []
|
|
deployable = []
|
|
|
|
if create_devices:
|
|
for device in concept.devices.select_related('device_type', 'role'):
|
|
if device.position is None:
|
|
warnings.append(_('%(device)s has no position and will not be created.') % {'device': device})
|
|
continue
|
|
if device.device_type is None:
|
|
warnings.append(
|
|
_('%(device)s is reserved space without a device type and will not be created.')
|
|
% {'device': device}
|
|
)
|
|
continue
|
|
if device.role is None:
|
|
warnings.append(
|
|
_('%(device)s has no role. NetBox requires one, so it will not be created.')
|
|
% {'device': device}
|
|
)
|
|
continue
|
|
if not device.name and skip_unnamed_devices:
|
|
warnings.append(_('%(device)s has no name and will be skipped.') % {'device': device})
|
|
continue
|
|
if device.status == ConceptDeviceStatusChoices.STATUS_OPTIONAL:
|
|
warnings.append(
|
|
_('%(device)s is marked optional but will be created.') % {'device': device}
|
|
)
|
|
deployable.append(device)
|
|
|
|
return deployable, warnings
|
|
|
|
|
|
@transaction.atomic
|
|
def deploy_concept(concept, rack_names, site, location=None, tenant=None, create_devices=True,
|
|
device_name_pattern='', skip_unnamed_devices=True, user=None):
|
|
"""
|
|
Materialise the blueprint as one real rack per name in `rack_names`.
|
|
Returns (racks, devices, warnings).
|
|
"""
|
|
config = _config()
|
|
rack_status = config.get('deployed_rack_status', 'planned')
|
|
device_status = config.get('deployed_device_status', 'planned')
|
|
|
|
planned, warnings = validate_deployment(concept, create_devices, skip_unnamed_devices)
|
|
|
|
racks = []
|
|
created_devices = []
|
|
|
|
for rack_name in rack_names:
|
|
rack = Rack(
|
|
name=rack_name,
|
|
site=site,
|
|
location=location,
|
|
tenant=tenant or concept.tenant,
|
|
status=rack_status,
|
|
role=concept.rack_role,
|
|
description=concept.description,
|
|
comments=concept.comments,
|
|
)
|
|
if concept.rack_type:
|
|
# Let NetBox derive the geometry from the rack type rather than fighting it.
|
|
rack.rack_type = concept.rack_type
|
|
else:
|
|
for field in GEOMETRY_FIELDS:
|
|
setattr(rack, field, getattr(concept, field))
|
|
rack.full_clean()
|
|
rack.save()
|
|
racks.append(rack)
|
|
|
|
rack_device_count = 0
|
|
for planned_device in planned:
|
|
name = planned_device.name
|
|
if name and device_name_pattern:
|
|
name = device_name_pattern.format(rack=rack_name, name=planned_device.name)
|
|
|
|
device = Device(
|
|
name=name or None,
|
|
device_type=planned_device.device_type,
|
|
role=planned_device.role,
|
|
platform=planned_device.platform,
|
|
site=site,
|
|
location=location,
|
|
rack=rack,
|
|
position=planned_device.position,
|
|
face=planned_device.face,
|
|
status=device_status,
|
|
tenant=planned_device.tenant or tenant or concept.tenant,
|
|
serial=planned_device.serial,
|
|
description=planned_device.description,
|
|
)
|
|
device.full_clean()
|
|
device.save()
|
|
apply_partial_width(device, planned_device)
|
|
created_devices.append(device)
|
|
rack_device_count += 1
|
|
|
|
ConceptDeployment.objects.create(
|
|
concept=concept,
|
|
rack=rack,
|
|
rack_name=rack_name,
|
|
device_count=rack_device_count,
|
|
user=user if user and user.is_authenticated else None,
|
|
)
|
|
|
|
return racks, created_devices, warnings
|