fix: preserve cable paths across export and import

This commit is contained in:
2026-08-24 16:41:57 +02:00
parent 05e33a87a9
commit e44e27d307
8 changed files with 175 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.11"
version = "0.3.12"
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.11",
"plugin_version": "0.3.12",
"scope": {
"type": scope_type,
"source_pk": str(scope_id),
+9 -1
View File
@@ -31,6 +31,11 @@ EXCLUDED_MODELS = {
}
SCOPE_LINK_FIELDS = {"tenant", "site", "location", "region"}
PEER_CONTAINER_MODELS = {"dcim.cable", "circuits.circuit", "circuits.virtualcircuit"}
PRIVATE_EXPORTABLE_MODELS = {
# NetBox derives cable paths from these mappings, but marks the model private
# because it has no public API of its own.
"dcim.portmapping",
}
def is_exportable_model(model) -> bool:
@@ -40,7 +45,10 @@ def is_exportable_model(model) -> bool:
and not opts.abstract
and not opts.proxy
and not opts.auto_created
and not getattr(model, "_netbox_private", False)
and (
not getattr(model, "_netbox_private", False)
or opts.label_lower in PRIVATE_EXPORTABLE_MODELS
)
and opts.app_label not in EXCLUDED_APP_LABELS
and opts.label_lower not in EXCLUDED_MODELS
)
+58
View File
@@ -46,6 +46,7 @@ EXPLICIT_IDENTITIES = {
"dcim.poweroutlet": ("device", "name"),
"dcim.frontport": ("device", "name"),
"dcim.rearport": ("device", "name"),
"dcim.portmapping": ("front_port", "front_port_position"),
"dcim.devicebay": ("device", "name"),
"dcim.modulebay": ("device", "module", "name"),
"dcim.inventoryitem": ("device", "parent", "name"),
@@ -390,6 +391,60 @@ def _write_mapping(source_instance, record, obj):
)
def _release_port_mapping_conflicts(obj, resolver, record_id):
if obj._meta.label_lower != "dcim.portmapping":
return
conflicts = type(obj)._default_manager.select_for_update().filter(
models.Q(
front_port_id=obj.front_port_id,
front_port_position=obj.front_port_position,
)
| models.Q(
rear_port_id=obj.rear_port_id,
rear_port_position=obj.rear_port_position,
)
)
if obj.pk is not None:
conflicts = conflicts.exclude(pk=obj.pk)
conflicts = list(conflicts)
if not conflicts:
return
conflict_ids = ", ".join(str(conflict.pk) for conflict in conflicts)
for conflict in conflicts:
conflict.delete()
resolver.warn(
("port-mapping-replaced", record_id),
f"Portzuordnung(en) {conflict_ids} wurden durch {record_id} ersetzt, damit die "
"Front-/Rear-Port-Verkabelung der Quelle entspricht.",
)
def _trace_paths_signal():
from dcim.models.cables import trace_paths
return trace_paths
def _rebuild_imported_cable_paths(records, resolved):
cable_ids = {
resolved[record_id].cable_id
for record_id, record in records.items()
if record["model"] == "dcim.cabletermination" and record_id in resolved
}
if not cable_ids:
return
cable_model = _model_for("dcim.cable")
trace_paths = _trace_paths_signal()
for cable in cable_model._default_manager.filter(pk__in=cable_ids).order_by("pk"):
# CableTermination records are imported directly, so Cable.save() never
# emits NetBox's normal path-rebuild signal for their new endpoints.
cable._terminations_modified = True
trace_paths.send(cable_model, instance=cable, created=False)
def _field_kwargs(model, record, resolver, *, tenant_required: bool):
valid_fields = {field.name: field for field in model._meta.concrete_fields}
kwargs = {}
@@ -699,6 +754,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
if device_placement is not None:
_stage_device_placement(obj)
compatibility.prepare_initial_save(obj, is_new=existing is None)
_release_port_mapping_conflicts(obj, resolver, record_id)
compatibility.save(obj)
action = "updated" if existing is not None else "created"
writable.add(record_id)
@@ -770,6 +826,8 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
if record_id in writable:
_apply_m2m(resolved[record_id], record, resolver)
_rebuild_imported_cable_paths(records, resolved)
if dry_run:
transaction.set_rollback(True)
except (IntegrityError, ValueError, TypeError) as exc: