diff --git a/README.md b/README.md index be1706b..de3fd83 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/netbox_export/__init__.py b/netbox_export/__init__.py index c24158e..4b40022 100644 --- a/netbox_export/__init__.py +++ b/netbox_export/__init__.py @@ -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" diff --git a/netbox_export/services/exporter.py b/netbox_export/services/exporter.py index 1a00334..b5634f4 100644 --- a/netbox_export/services/exporter.py +++ b/netbox_export/services/exporter.py @@ -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), diff --git a/netbox_export/services/graph.py b/netbox_export/services/graph.py index e4576c0..5c1cabf 100644 --- a/netbox_export/services/graph.py +++ b/netbox_export/services/graph.py @@ -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 ) diff --git a/netbox_export/services/importer.py b/netbox_export/services/importer.py index c07878f..acb618d 100644 --- a/netbox_export/services/importer.py +++ b/netbox_export/services/importer.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 8797074..444c4b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_cable_import.py b/tests/test_cable_import.py new file mode 100644 index 0000000..11d73fc --- /dev/null +++ b/tests/test_cable_import.py @@ -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) diff --git a/tests/test_graph.py b/tests/test_graph.py index 3ed96a9..2a0c806 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -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: