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
+6
View File
@@ -135,6 +135,12 @@ Belegungen löst **Aktualisieren** das Zielgerät mit Warnung von seiner Positio
**Überspringen** lässt das importierte Gerät positionslos und **Import abbrechen**
meldet den Rackplatzkonflikt vor dem Datenbankfehler. Mehr-U- und
Full-Depth-Belegungen werden dabei berücksichtigt.
Front-/Rear-Port-Zuordnungen von Patchpanels werden als eigene Datensätze
exportiert und bei **Aktualisieren** auf den Stand der Quelle gebracht. Nach dem
Import stößt das Plugin für alle enthaltenen Kabel die NetBox-eigene
Neuberechnung der Kabelpfade an. Für diese Korrektur muss mit Plugin-Version
`0.3.12` oder neuer ein neues Archiv auf der Quellinstanz erzeugt werden, da
ältere Archive keine Portzuordnungen enthalten.
Bei Bildanhängen werden Breite und Höhe direkt aus der Bilddatei im Archiv
ermittelt. Dadurch sind die Pflichtfelder von NetBox auch im Prüflauf und bei
Dateispeichern ohne unmittelbaren Modell-Save gesetzt. Bilder oberhalb des in
+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:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netbox-export"
version = "0.3.11"
version = "0.3.12"
description = "Portable ZIP export and import for scoped NetBox data"
readme = "README.md"
requires-python = ">=3.12"
+80
View File
@@ -0,0 +1,80 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
from netbox_export.services import importer
from netbox_export.services.importer import (
_rebuild_imported_cable_paths,
_release_port_mapping_conflicts,
)
class FakeQuerySet(list):
def exclude(self, **kwargs):
return FakeQuerySet(item for item in self if item.pk != kwargs["pk"])
def order_by(self, *args):
return self
class FakeManager:
def __init__(self, objects):
self.objects = FakeQuerySet(objects)
self.filters = []
def select_for_update(self):
return self
def filter(self, *args, **kwargs):
self.filters.append((args, kwargs))
return FakeQuerySet(self.objects)
def test_port_mapping_conflicts_are_replaced_before_save():
deleted = []
conflict = SimpleNamespace(pk=12, delete=lambda: deleted.append(12))
class PortMapping:
_meta = SimpleNamespace(label_lower="dcim.portmapping")
_default_manager = FakeManager([conflict])
mapping = PortMapping()
mapping.pk = 11
mapping.front_port_id = 21
mapping.front_port_position = 1
mapping.rear_port_id = 31
mapping.rear_port_position = 1
resolver = SimpleNamespace(warn=MagicMock())
_release_port_mapping_conflicts(mapping, resolver, "dcim.portmapping:99")
assert deleted == [12]
resolver.warn.assert_called_once()
assert "Front-/Rear-Port-Verkabelung" in resolver.warn.call_args.args[1]
def test_imported_cable_terminations_trigger_netbox_path_rebuild(monkeypatch):
cables = [SimpleNamespace(pk=7), SimpleNamespace(pk=9)]
class Cable:
_default_manager = FakeManager(cables)
trace_paths = SimpleNamespace(send=MagicMock())
monkeypatch.setattr(importer, "_model_for", lambda label: Cable)
monkeypatch.setattr(importer, "_trace_paths_signal", lambda: trace_paths)
records = {
"dcim.cabletermination:1": {"model": "dcim.cabletermination"},
"dcim.cabletermination:2": {"model": "dcim.cabletermination"},
"dcim.device:3": {"model": "dcim.device"},
}
resolved = {
"dcim.cabletermination:1": SimpleNamespace(cable_id=7),
"dcim.cabletermination:2": SimpleNamespace(cable_id=9),
"dcim.device:3": object(),
}
_rebuild_imported_cable_paths(records, resolved)
assert all(cable._terminations_modified is True for cable in cables)
assert trace_paths.send.call_count == 2
trace_paths.send.assert_any_call(Cable, instance=cables[0], created=False)
trace_paths.send.assert_any_call(Cable, instance=cables[1], created=False)
+19
View File
@@ -43,10 +43,29 @@ class PrivateGraphModel(models.Model):
app_label = "graph_tests"
class PortMapping(models.Model):
_netbox_private = True
class Meta:
app_label = "dcim"
class CablePath(models.Model):
_netbox_private = True
class Meta:
app_label = "dcim"
def test_private_plugin_models_are_not_exportable():
assert graph_module.is_exportable_model(PrivateGraphModel) is False
def test_private_port_mappings_are_exported_but_cable_paths_are_not():
assert graph_module.is_exportable_model(PortMapping) is True
assert graph_module.is_exportable_model(CablePath) is False
def test_batched_graph_collects_members_and_dependencies(monkeypatch):
graph_models = (GraphParent, GraphReference, GraphChild, GraphDetail)
with connection.schema_editor() as schema_editor: