From 8107e152d2b72ad29c0d73e14419dd6b6d676e93 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 5 Aug 2026 14:05:40 +0200 Subject: [PATCH] fix: reconcile component mappings by natural key --- README.md | 3 ++ netbox_export/__init__.py | 2 +- netbox_export/services/exporter.py | 2 +- netbox_export/services/importer.py | 17 ++++--- pyproject.toml | 2 +- tests/test_importer_plugin_compat.py | 66 +++++++++++++++++++++++++++- 6 files changed, 83 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b344058..dbb46fd 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,9 @@ anderem die Plattformzuordnung von NetBox-SLM-Softwareinstallationen. Eindeutige optionale Beziehungen wie die primären IP-Adressen von Geräten und virtuellen Maschinen werden in einer zweiten Phase zugewiesen. Eine veraltete Zielzuordnung wird dabei atomar gelöst und als Warnung protokolliert. +Gespeicherte Importzuordnungen werden bei Wiederholungsimporten gegen den +aktuellen Fachschlüssel geprüft. Existiert das Objekt bereits unter diesem +Schlüssel, wird die Zuordnung korrigiert, statt ein Duplikat anzulegen. Auf Quelle und Ziel müssen jeweils dieselben Plugin-Versionen und Migrationen installiert sein. Verschlüsselte Zugangsdaten von NetBox-VM-Import sind nur bei diff --git a/netbox_export/__init__.py b/netbox_export/__init__.py index 134e43a..93e6afb 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.6" + version = "0.3.7" 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 6615abe..e19b354 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.6", + "plugin_version": "0.3.7", "scope": { "type": scope_type, "source_pk": str(scope_id), diff --git a/netbox_export/services/importer.py b/netbox_export/services/importer.py index 5dea01a..a77cb6f 100644 --- a/netbox_export/services/importer.py +++ b/netbox_export/services/importer.py @@ -38,6 +38,8 @@ EXPLICIT_IDENTITIES = { "dcim.consoleserverport": ("device", "name"), "dcim.powerport": ("device", "name"), "dcim.poweroutlet": ("device", "name"), + "dcim.frontport": ("device", "name"), + "dcim.rearport": ("device", "name"), "dcim.devicebay": ("device", "name"), "dcim.modulebay": ("device", "name"), "dcim.inventoryitem": ("device", "parent", "name"), @@ -191,19 +193,24 @@ def _mapped_object(source_instance: uuid.UUID, record: dict, model): def _find_existing(source_instance, model, record, resolver): mapped = _mapped_object(source_instance, record, model) - if mapped is not None: - return mapped lookup = _identity_lookup(model, record, resolver) if not lookup: - return None + return mapped try: - return model._default_manager.get(**lookup) + natural = model._default_manager.get(**lookup) except model.DoesNotExist: - return None + return mapped except model.MultipleObjectsReturned as exc: raise ImportConflictError( f"Mehrere Zielobjekte passen auf {record['model']} mit {lookup}." ) from exc + if mapped is not None and mapped.pk != natural.pk: + resolver.warn( + ("mapping-rebound", record["id"]), + f"Gespeicherte Zuordnung für {record['id']} wurde von Ziel-ID {mapped.pk} auf " + f"Ziel-ID {natural.pk} korrigiert, da der Fachschlüssel {lookup} bereits existiert.", + ) + return natural def _write_mapping(source_instance, record, obj): diff --git a/pyproject.toml b/pyproject.toml index 2adfa51..5bbaf9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-export" -version = "0.3.6" +version = "0.3.7" description = "Portable ZIP export and import for scoped NetBox data" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_importer_plugin_compat.py b/tests/test_importer_plugin_compat.py index c0511f2..0778568 100644 --- a/tests/test_importer_plugin_compat.py +++ b/tests/test_importer_plugin_compat.py @@ -1,9 +1,11 @@ +import uuid from typing import ClassVar import pytest from django.db import connection, models -from netbox_export.services.importer import _field_kwargs +from netbox_export.services import importer as importer_module +from netbox_export.services.importer import _field_kwargs, _find_existing from netbox_export.services.plugin_compat import PluginCompatibility from netbox_export.services.references import ReferenceResolver @@ -52,6 +54,25 @@ class UniqueAddressOwner(models.Model): app_label = "compat_tests" +class Device(models.Model): + class Meta: + app_label = "dcim" + + +class RearPort(models.Model): + device = models.ForeignKey(Device, on_delete=models.CASCADE) + name = models.CharField(max_length=64) + + class Meta: + app_label = "dcim" + constraints: ClassVar[list] = [ + models.UniqueConstraint( + fields=("device", "name"), + name="dcim_rearport_unique_device_name", + ) + ] + + def tenant_record(): return { "fields": {}, @@ -164,3 +185,46 @@ def test_existing_unique_relation_is_released_before_reassignment(): with connection.schema_editor() as schema_editor: schema_editor.delete_model(UniqueAddressOwner) schema_editor.delete_model(UniqueAddress) + + +def test_natural_key_rebinds_stale_mapping_instead_of_creating_duplicate(monkeypatch): + with connection.schema_editor() as schema_editor: + schema_editor.create_model(Device) + schema_editor.create_model(RearPort) + try: + device = Device.objects.create() + natural = RearPort.objects.create(device=device, name="3-LC") + stale_mapping_target = RearPort(pk=999, device=device, name="Alt") + monkeypatch.setattr( + importer_module, + "_mapped_object", + lambda source_instance, record, model: stale_mapping_target, + ) + warnings = [] + resolver = ReferenceResolver( + {"dcim.device:160": device}, + warnings, + lambda label: None, + ) + record = { + "id": "dcim.rearport:77", + "model": "dcim.rearport", + "source_pk": "77", + "fields": {"name": "3-LC"}, + "relations": {"device": {"ref": "dcim.device:160"}}, + } + + existing = _find_existing(uuid.uuid4(), RearPort, record, resolver) + + assert existing == natural + assert warnings == [ + ( + "Gespeicherte Zuordnung für dcim.rearport:77 wurde von Ziel-ID 999 auf " + f"Ziel-ID {natural.pk} korrigiert, da der Fachschlüssel " + f"{{'device': , 'name': '3-LC'}} bereits existiert." + ) + ] + finally: + with connection.schema_editor() as schema_editor: + schema_editor.delete_model(RearPort) + schema_editor.delete_model(Device)