fix: preserve cable paths across export and import
This commit is contained in:
@@ -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**
|
**Überspringen** lässt das importierte Gerät positionslos und **Import abbrechen**
|
||||||
meldet den Rackplatzkonflikt vor dem Datenbankfehler. Mehr-U- und
|
meldet den Rackplatzkonflikt vor dem Datenbankfehler. Mehr-U- und
|
||||||
Full-Depth-Belegungen werden dabei berücksichtigt.
|
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
|
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
|
ermittelt. Dadurch sind die Pflichtfelder von NetBox auch im Prüflauf und bei
|
||||||
Dateispeichern ohne unmittelbaren Modell-Save gesetzt. Bilder oberhalb des in
|
Dateispeichern ohne unmittelbaren Modell-Save gesetzt. Bilder oberhalb des in
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class NetBoxExportConfig(PluginConfig):
|
|||||||
name = "netbox_export"
|
name = "netbox_export"
|
||||||
verbose_name = "NetBox-Export"
|
verbose_name = "NetBox-Export"
|
||||||
description = "Portable ZIP export and import for tenants and locations"
|
description = "Portable ZIP export and import for tenants and locations"
|
||||||
version = "0.3.11"
|
version = "0.3.12"
|
||||||
author = "NetBox Export contributors"
|
author = "NetBox Export contributors"
|
||||||
base_url = "netbox-export"
|
base_url = "netbox-export"
|
||||||
min_version = "4.6.0"
|
min_version = "4.6.0"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def export_scope(
|
|||||||
"created_at": datetime.now(UTC).isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
"source_instance": str(InstanceIdentity.local_id()),
|
"source_instance": str(InstanceIdentity.local_id()),
|
||||||
"source_netbox_version": getattr(getattr(settings, "RELEASE", None), "version", "4.6"),
|
"source_netbox_version": getattr(getattr(settings, "RELEASE", None), "version", "4.6"),
|
||||||
"plugin_version": "0.3.11",
|
"plugin_version": "0.3.12",
|
||||||
"scope": {
|
"scope": {
|
||||||
"type": scope_type,
|
"type": scope_type,
|
||||||
"source_pk": str(scope_id),
|
"source_pk": str(scope_id),
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ EXCLUDED_MODELS = {
|
|||||||
}
|
}
|
||||||
SCOPE_LINK_FIELDS = {"tenant", "site", "location", "region"}
|
SCOPE_LINK_FIELDS = {"tenant", "site", "location", "region"}
|
||||||
PEER_CONTAINER_MODELS = {"dcim.cable", "circuits.circuit", "circuits.virtualcircuit"}
|
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:
|
def is_exportable_model(model) -> bool:
|
||||||
@@ -40,7 +45,10 @@ def is_exportable_model(model) -> bool:
|
|||||||
and not opts.abstract
|
and not opts.abstract
|
||||||
and not opts.proxy
|
and not opts.proxy
|
||||||
and not opts.auto_created
|
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.app_label not in EXCLUDED_APP_LABELS
|
||||||
and opts.label_lower not in EXCLUDED_MODELS
|
and opts.label_lower not in EXCLUDED_MODELS
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ EXPLICIT_IDENTITIES = {
|
|||||||
"dcim.poweroutlet": ("device", "name"),
|
"dcim.poweroutlet": ("device", "name"),
|
||||||
"dcim.frontport": ("device", "name"),
|
"dcim.frontport": ("device", "name"),
|
||||||
"dcim.rearport": ("device", "name"),
|
"dcim.rearport": ("device", "name"),
|
||||||
|
"dcim.portmapping": ("front_port", "front_port_position"),
|
||||||
"dcim.devicebay": ("device", "name"),
|
"dcim.devicebay": ("device", "name"),
|
||||||
"dcim.modulebay": ("device", "module", "name"),
|
"dcim.modulebay": ("device", "module", "name"),
|
||||||
"dcim.inventoryitem": ("device", "parent", "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):
|
def _field_kwargs(model, record, resolver, *, tenant_required: bool):
|
||||||
valid_fields = {field.name: field for field in model._meta.concrete_fields}
|
valid_fields = {field.name: field for field in model._meta.concrete_fields}
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
@@ -699,6 +754,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
|
|||||||
if device_placement is not None:
|
if device_placement is not None:
|
||||||
_stage_device_placement(obj)
|
_stage_device_placement(obj)
|
||||||
compatibility.prepare_initial_save(obj, is_new=existing is None)
|
compatibility.prepare_initial_save(obj, is_new=existing is None)
|
||||||
|
_release_port_mapping_conflicts(obj, resolver, record_id)
|
||||||
compatibility.save(obj)
|
compatibility.save(obj)
|
||||||
action = "updated" if existing is not None else "created"
|
action = "updated" if existing is not None else "created"
|
||||||
writable.add(record_id)
|
writable.add(record_id)
|
||||||
@@ -770,6 +826,8 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
|
|||||||
if record_id in writable:
|
if record_id in writable:
|
||||||
_apply_m2m(resolved[record_id], record, resolver)
|
_apply_m2m(resolved[record_id], record, resolver)
|
||||||
|
|
||||||
|
_rebuild_imported_cable_paths(records, resolved)
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
transaction.set_rollback(True)
|
transaction.set_rollback(True)
|
||||||
except (IntegrityError, ValueError, TypeError) as exc:
|
except (IntegrityError, ValueError, TypeError) as exc:
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-export"
|
name = "netbox-export"
|
||||||
version = "0.3.11"
|
version = "0.3.12"
|
||||||
description = "Portable ZIP export and import for scoped NetBox data"
|
description = "Portable ZIP export and import for scoped NetBox data"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -43,10 +43,29 @@ class PrivateGraphModel(models.Model):
|
|||||||
app_label = "graph_tests"
|
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():
|
def test_private_plugin_models_are_not_exportable():
|
||||||
assert graph_module.is_exportable_model(PrivateGraphModel) is False
|
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):
|
def test_batched_graph_collects_members_and_dependencies(monkeypatch):
|
||||||
graph_models = (GraphParent, GraphReference, GraphChild, GraphDetail)
|
graph_models = (GraphParent, GraphReference, GraphChild, GraphDetail)
|
||||||
with connection.schema_editor() as schema_editor:
|
with connection.schema_editor() as schema_editor:
|
||||||
|
|||||||
Reference in New Issue
Block a user