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
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
+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.6"
version = "0.3.7"
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.6",
"plugin_version": "0.3.7",
"scope": {
"type": scope_type,
"source_pk": str(scope_id),
+12 -5
View File
@@ -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):
+1 -1
View File
@@ -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"
+65 -1
View File
@@ -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': <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)