fix: reconcile rack placement conflicts during import

This commit is contained in:
2026-08-05 15:01:28 +02:00
parent d4ba5de2e7
commit 4ba41953c1
6 changed files with 479 additions and 4 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ class NetBoxExportConfig(PluginConfig):
name = "netbox_export"
verbose_name = "NetBox-Export"
description = "Portable ZIP export and import for tenants and locations"
version = "0.3.8"
version = "0.3.9"
author = "NetBox Export contributors"
base_url = "netbox-export"
min_version = "4.6.0"
+1 -1
View File
@@ -39,7 +39,7 @@ def export_scope(
"created_at": datetime.now(UTC).isoformat(),
"source_instance": str(InstanceIdentity.local_id()),
"source_netbox_version": getattr(getattr(settings, "RELEASE", None), "version", "4.6"),
"plugin_version": "0.3.8",
"plugin_version": "0.3.9",
"scope": {
"type": scope_type,
"source_pk": str(scope_id),
+179 -1
View File
@@ -4,6 +4,7 @@ import logging
import uuid
from dataclasses import dataclass
from dataclasses import field as dataclass_field
from decimal import Decimal
from django.apps import apps
from django.conf import settings
@@ -75,6 +76,13 @@ class ImportReport:
counters[action] += 1
@dataclass(frozen=True)
class DeferredDevicePlacement:
rack_spec: dict | None
position: object
face: object
def _model_for(label: str):
try:
model = apps.get_model(label)
@@ -213,6 +221,155 @@ def _find_existing(source_instance, model, record, resolver):
return natural
def _defer_device_placement(model, record):
if model._meta.label_lower != "dcim.device":
return record, None
fields = record.get("fields", {})
relations = record.get("relations", {})
if "rack" not in relations or "position" not in fields or "face" not in fields:
return record, None
prepared = dict(record)
prepared["fields"] = {
name: value for name, value in fields.items() if name not in ("position", "face")
}
prepared["relations"] = {name: value for name, value in relations.items() if name != "rack"}
placement = DeferredDevicePlacement(
rack_spec=relations["rack"],
position=decode_scalar(fields["position"]),
face=decode_scalar(fields["face"]),
)
return prepared, placement
def _placement_can_be_applied(placement, resolver):
rack, available = resolver.resolve(placement.rack_spec)
return not (available and rack is MISSING_REFERENCE)
def _stage_device_placement(device):
device.position = None
device.face = None
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 _device_placement_conflicts(device, rack, position, face):
if rack is None or position is None:
return []
position = Decimal(str(position))
height, full_depth = _device_footprint(device)
end = position + height
candidates = (
type(device)
._default_manager.select_for_update()
.select_related("device_type")
.filter(rack=rack, position__isnull=False)
.exclude(pk=device.pk)
)
conflicts = []
for candidate in candidates:
candidate_position = Decimal(str(candidate.position))
candidate_height, candidate_full_depth = _device_footprint(candidate)
faces_overlap = full_depth or candidate_full_depth or candidate.face == face
positions_overlap = position < candidate_position + candidate_height and candidate_position < end
if faces_overlap and positions_overlap:
conflicts.append(candidate)
return conflicts
def _placement_target(rack, position, face):
rack_name = getattr(rack, "name", None) or str(rack.pk)
return f"Rack {rack_name}, Position {position}, Seite {face or '-'}"
def _conflicting_devices(conflicts):
return ", ".join(
f"{device.pk} ({getattr(device, 'name', None) or 'ohne Namen'})" for device in conflicts
)
def _save_device_placement(device, rack, position, face, compatibility):
device.rack = rack
device.position = position
device.face = face
update_fields = ["rack", "position", "face"]
rack_location = getattr(rack, "location", None)
if rack_location is not None:
device.location = rack_location
update_fields.append("location")
compatibility.save(device, update_fields=update_fields)
def _apply_device_placements(
placements,
resolved,
resolver,
compatibility,
*,
conflict_strategy,
):
for record_id, placement in placements:
rack, available = resolver.resolve(placement.rack_spec)
if not available:
raise ArchiveValidationError(f"Rack-Referenz für {record_id} konnte nicht aufgelöst werden.")
if rack is MISSING_REFERENCE:
continue
device = resolved[record_id]
position = placement.position
face = placement.face
if rack is None and (position is not None or face is not None):
resolver.warn(
("device-placement-without-rack", record_id),
f"Rackplatz für {record_id} wurde ausgelassen, da kein Rack zugeordnet ist.",
)
position = None
face = None
if rack is not None and position is not None:
rack = type(rack)._default_manager.select_for_update().get(pk=rack.pk)
conflicts = _device_placement_conflicts(device, rack, position, face)
if not conflicts:
_save_device_placement(device, rack, position, face, compatibility)
continue
target = _placement_target(rack, position, face)
occupants = _conflicting_devices(conflicts)
if conflict_strategy == "fail":
raise ImportConflictError(
f"Rackplatzkonflikt für {record_id}: {target} ist durch Zielgerät(e) {occupants} belegt."
)
if conflict_strategy == "skip":
_save_device_placement(device, rack, None, None, compatibility)
resolver.warn(
("device-placement-skipped", record_id),
f"Rackplatz für {record_id} wurde übersprungen: {target} bleibt durch "
f"Zielgerät(e) {occupants} belegt. Das importierte Gerät wurde ohne Position im Rack gespeichert.",
)
continue
for conflict in conflicts:
conflict.position = None
conflict.face = None
compatibility.save(conflict, update_fields=["position", "face"])
_save_device_placement(device, rack, position, face, compatibility)
resolver.warn(
("device-placement-released", record_id),
f"Rackplatzkonflikt für {record_id} gelöst: Zielgerät(e) {occupants} wurden aus {target} "
"gelöst und ohne Position im Rack belassen.",
)
def _write_mapping(source_instance, record, obj):
content_type = ContentType.objects.get_for_model(obj, for_concrete_model=False)
ImportedObjectMapping.objects.update_or_create(
@@ -370,6 +527,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
deferred_relations = []
deferred_generic = []
deferred_values = []
deferred_device_placements = []
writable = set()
saved_files = []
@@ -380,8 +538,16 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
progressed = False
for record_id, record in list(pending.items()):
model = _model_for(record["model"])
prepared_record, device_placement = _defer_device_placement(model, record)
if device_placement is not None and not _placement_can_be_applied(
device_placement, resolver
):
device_placement = None
kwargs, unresolved, unresolved_value_fields, missing_required = _field_kwargs(
model, record, resolver, tenant_required=compatibility.tenant_required
model,
prepared_record,
resolver,
tenant_required=compatibility.tenant_required,
)
if kwargs is None:
continue
@@ -411,6 +577,8 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
progressed = True
continue
_set_files(obj, record, parsed.assets, saved_files, dry_run=dry_run)
if device_placement is not None:
_stage_device_placement(obj)
compatibility.prepare_initial_save(obj, is_new=existing is None)
compatibility.save(obj)
action = "updated" if existing is not None else "created"
@@ -420,6 +588,8 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
deferred_values.extend(
(record_id, name, encoded) for name, encoded in unresolved_value_fields
)
if device_placement is not None:
deferred_device_placements.append((record_id, device_placement))
resolved[record_id] = obj
_write_mapping(source_instance, record, obj)
@@ -469,6 +639,14 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
setattr(obj, name, value)
compatibility.save(obj, update_fields=[name])
_apply_device_placements(
deferred_device_placements,
resolved,
resolver,
compatibility,
conflict_strategy=conflict_strategy,
)
for record_id, record in records.items():
if record_id in writable:
_apply_m2m(resolved[record_id], record, resolver)