feat: preserve rack widths in netbox export

This commit is contained in:
2026-08-19 10:33:17 +02:00
parent 839b076d94
commit 639dc958a5
7 changed files with 426 additions and 7 deletions
+200
View File
@@ -0,0 +1,200 @@
import logging
from dataclasses import dataclass
from decimal import Decimal
from functools import wraps
from importlib import import_module
from django.apps import apps
from django.core.exceptions import ValidationError
from .models import DeviceRackPlacement
from .rack_width import (
FULL_WIDTH,
get_width_position,
normalize_width_position,
placement_rectangles_overlap,
stage_width_position,
)
logger = logging.getLogger(__name__)
SUPPORTED_NETBOX_EXPORT_VERSIONS = {"0.3.11"}
RACK_WIDTH_RECORD_KEY = "netbox_utilities_rack_placement"
PATCH_MARKER = "_netbox_utilities_rack_width_version"
PATCH_VERSION = 1
@dataclass(frozen=True)
class WidthAwareDeferredDevicePlacement:
rack_spec: dict | None
position: object
face: object
width: int
horizontal_position: int
def _is_device(value):
return getattr(getattr(value, "_meta", None), "label_lower", None) == "dcim.device"
def _serialize_rack_width(original, obj, *args, **kwargs):
record = original(obj, *args, **kwargs)
if _is_device(obj):
width, horizontal_position = get_width_position(obj)
record[RACK_WIDTH_RECORD_KEY] = {
"schema_version": 1,
"width": width,
"horizontal_position": horizontal_position,
}
return record
def _defer_rack_width(original, model, record):
prepared, placement = original(model, record)
if placement is None:
return prepared, placement
metadata = record.get(RACK_WIDTH_RECORD_KEY)
if not isinstance(metadata, dict) or metadata.get("schema_version") != 1:
return prepared, placement
try:
width, horizontal_position = normalize_width_position(
metadata.get("width"),
metadata.get("horizontal_position"),
)
except ValidationError:
logger.warning("NetBox Export archive contains invalid rack width metadata for %s", record.get("id"))
return prepared, placement
return prepared, WidthAwareDeferredDevicePlacement(
rack_spec=placement.rack_spec,
position=placement.position,
face=placement.face,
width=width,
horizontal_position=horizontal_position,
)
def _device_footprint(device):
device_type = getattr(device, "device_type", None)
height = Decimal(str(getattr(device_type, "u_height", 1) or 0))
if height <= 0:
height = Decimal("0.5")
return height, bool(getattr(device_type, "is_full_depth", False))
def _width_aware_device_placement_conflicts(original, device, rack, position, face):
if not _is_device(device):
return original(device, rack, position, face)
if rack is None or position is None:
return []
width, horizontal_position = get_width_position(device)
height, full_depth = _device_footprint(device)
candidates = (
type(device)
._default_manager.select_for_update()
.select_related("device_type", "netbox_utilities_rack_placement")
.filter(rack=rack, position__isnull=False)
.exclude(pk=device.pk)
)
conflicts = []
for candidate in candidates:
candidate_height, candidate_full_depth = _device_footprint(candidate)
if not (full_depth or candidate_full_depth or candidate.face == face):
continue
candidate_width, candidate_horizontal_position = get_width_position(candidate)
if placement_rectangles_overlap(
position,
height,
width,
horizontal_position,
candidate.position,
candidate_height,
candidate_width,
candidate_horizontal_position,
):
conflicts.append(candidate)
return conflicts
def _apply_rack_widths(original, placements, resolved, resolver, compatibility, **kwargs):
placements = list(placements)
width_aware = []
for record_id, placement in placements:
if not isinstance(placement, WidthAwareDeferredDevicePlacement):
continue
device = resolved[record_id]
stage_width_position(device, placement.width, placement.horizontal_position)
width_aware.append((device, placement))
result = original(placements, resolved, resolver, compatibility, **kwargs)
for device, placement in width_aware:
if placement.width == FULL_WIDTH or not device.rack_id or not device.position:
DeviceRackPlacement.objects.filter(device=device).delete()
else:
DeviceRackPlacement.objects.update_or_create(
device=device,
defaults={
"width": placement.width,
"horizontal_position": placement.horizontal_position,
},
)
return result
def _rack_placement_is_exportable(original, model):
if model is DeviceRackPlacement:
return True
return original(model)
def _patch_function(module, name, wrapper_factory):
current = getattr(module, name)
if getattr(current, PATCH_MARKER, None) == PATCH_VERSION:
return
original = current
@wraps(original)
def patched(*args, **kwargs):
return wrapper_factory(original, *args, **kwargs)
setattr(patched, PATCH_MARKER, PATCH_VERSION)
setattr(module, name, patched)
def install_netbox_export_rack_width_support():
if not apps.is_installed("netbox_export"):
return False
plugin_config = apps.get_app_config("netbox_export")
plugin_version = getattr(plugin_config, "version", None)
if plugin_version not in SUPPORTED_NETBOX_EXPORT_VERSIONS:
logger.warning(
"NetBox Utilities did not patch netbox-export version %s; supported versions: %s",
plugin_version,
", ".join(sorted(SUPPORTED_NETBOX_EXPORT_VERSIONS)),
)
return False
exporter = import_module("netbox_export.services.exporter")
graph = import_module("netbox_export.services.graph")
importer = import_module("netbox_export.services.importer")
required = (
(exporter, "serialize_object"),
(graph, "is_exportable_model"),
(importer, "_defer_device_placement"),
(importer, "_device_placement_conflicts"),
(importer, "_apply_device_placements"),
)
if not all(hasattr(module, name) for module, name in required):
logger.warning("NetBox Utilities could not find the expected netbox-export 0.3.11 integration hooks")
return False
_patch_function(exporter, "serialize_object", _serialize_rack_width)
_patch_function(graph, "is_exportable_model", _rack_placement_is_exportable)
_patch_function(importer, "_defer_device_placement", _defer_rack_width)
_patch_function(importer, "_device_placement_conflicts", _width_aware_device_placement_conflicts)
_patch_function(importer, "_apply_device_placements", _apply_rack_widths)
return True