fix: reconcile component mappings by natural key

This commit is contained in:
2026-08-05 14:05:40 +02:00
parent f22276d68d
commit 8107e152d2
6 changed files with 83 additions and 9 deletions
+3
View File
@@ -122,6 +122,9 @@ anderem die Plattformzuordnung von NetBox-SLM-Softwareinstallationen.
Eindeutige optionale Beziehungen wie die primären IP-Adressen von Geräten und Eindeutige optionale Beziehungen wie die primären IP-Adressen von Geräten und
virtuellen Maschinen werden in einer zweiten Phase zugewiesen. Eine veraltete virtuellen Maschinen werden in einer zweiten Phase zugewiesen. Eine veraltete
Zielzuordnung wird dabei atomar gelöst und als Warnung protokolliert. 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 Auf Quelle und Ziel müssen jeweils dieselben Plugin-Versionen und Migrationen
installiert sein. Verschlüsselte Zugangsdaten von NetBox-VM-Import sind nur bei installiert sein. Verschlüsselte Zugangsdaten von NetBox-VM-Import sind nur bei
+1 -1
View File
@@ -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.6" version = "0.3.7"
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"
+1 -1
View File
@@ -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.6", "plugin_version": "0.3.7",
"scope": { "scope": {
"type": scope_type, "type": scope_type,
"source_pk": str(scope_id), "source_pk": str(scope_id),
+12 -5
View File
@@ -38,6 +38,8 @@ EXPLICIT_IDENTITIES = {
"dcim.consoleserverport": ("device", "name"), "dcim.consoleserverport": ("device", "name"),
"dcim.powerport": ("device", "name"), "dcim.powerport": ("device", "name"),
"dcim.poweroutlet": ("device", "name"), "dcim.poweroutlet": ("device", "name"),
"dcim.frontport": ("device", "name"),
"dcim.rearport": ("device", "name"),
"dcim.devicebay": ("device", "name"), "dcim.devicebay": ("device", "name"),
"dcim.modulebay": ("device", "name"), "dcim.modulebay": ("device", "name"),
"dcim.inventoryitem": ("device", "parent", "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): def _find_existing(source_instance, model, record, resolver):
mapped = _mapped_object(source_instance, record, model) mapped = _mapped_object(source_instance, record, model)
if mapped is not None:
return mapped
lookup = _identity_lookup(model, record, resolver) lookup = _identity_lookup(model, record, resolver)
if not lookup: if not lookup:
return None return mapped
try: try:
return model._default_manager.get(**lookup) natural = model._default_manager.get(**lookup)
except model.DoesNotExist: except model.DoesNotExist:
return None return mapped
except model.MultipleObjectsReturned as exc: except model.MultipleObjectsReturned as exc:
raise ImportConflictError( raise ImportConflictError(
f"Mehrere Zielobjekte passen auf {record['model']} mit {lookup}." f"Mehrere Zielobjekte passen auf {record['model']} mit {lookup}."
) from exc ) 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): def _write_mapping(source_instance, record, obj):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-export" name = "netbox-export"
version = "0.3.6" version = "0.3.7"
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"
+65 -1
View File
@@ -1,9 +1,11 @@
import uuid
from typing import ClassVar from typing import ClassVar
import pytest import pytest
from django.db import connection, models 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.plugin_compat import PluginCompatibility
from netbox_export.services.references import ReferenceResolver from netbox_export.services.references import ReferenceResolver
@@ -52,6 +54,25 @@ class UniqueAddressOwner(models.Model):
app_label = "compat_tests" 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(): def tenant_record():
return { return {
"fields": {}, "fields": {},
@@ -164,3 +185,46 @@ def test_existing_unique_relation_is_released_before_reassignment():
with connection.schema_editor() as schema_editor: with connection.schema_editor() as schema_editor:
schema_editor.delete_model(UniqueAddressOwner) schema_editor.delete_model(UniqueAddressOwner)
schema_editor.delete_model(UniqueAddress) 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': <Device: Device object ({device.pk})>, 'name': '3-LC'}} bereits existiert."
)
]
finally:
with connection.schema_editor() as schema_editor:
schema_editor.delete_model(RearPort)
schema_editor.delete_model(Device)