fix: reconcile component mappings by natural key
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user