fix: prevent module component duplication during import

This commit is contained in:
2026-08-05 14:24:46 +02:00
parent 8107e152d2
commit d4ba5de2e7
8 changed files with 119 additions and 7 deletions
+4
View File
@@ -125,6 +125,10 @@ 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.
Bei neuen NetBox-Modulen wird die automatische Komponentenreplikation
deaktiviert. Ports, Interfaces und Bays werden stattdessen ausschließlich aus
den Archivdatensätzen angelegt beziehungsweise vorhandenen Komponenten
zugeordnet.
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.7"
version = "0.3.8"
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.7",
"plugin_version": "0.3.8",
"scope": {
"type": scope_type,
"source_pk": str(scope_id),
+2 -1
View File
@@ -41,7 +41,7 @@ EXPLICIT_IDENTITIES = {
"dcim.frontport": ("device", "name"),
"dcim.rearport": ("device", "name"),
"dcim.devicebay": ("device", "name"),
"dcim.modulebay": ("device", "name"),
"dcim.modulebay": ("device", "module", "name"),
"dcim.inventoryitem": ("device", "parent", "name"),
"ipam.prefix": ("vrf", "prefix"),
"ipam.ipaddress": ("vrf", "address"),
@@ -411,6 +411,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
progressed = True
continue
_set_files(obj, record, parsed.assets, saved_files, dry_run=dry_run)
compatibility.prepare_initial_save(obj, is_new=existing is None)
compatibility.save(obj)
action = "updated" if existing is not None else "created"
writable.add(record_id)
+18 -2
View File
@@ -27,6 +27,13 @@ def is_tenant_relation(field) -> bool:
return getattr(getattr(related_model, "_meta", None), "label_lower", None) == "tenancy.tenant"
def is_module_relation(field) -> bool:
if field.name != "module":
return False
related_model = getattr(field.remote_field, "model", None)
return getattr(getattr(related_model, "_meta", None), "label_lower", None) == "dcim.module"
def _condition_field_names(condition):
for child in getattr(condition, "children", ()):
if isinstance(child, tuple):
@@ -47,8 +54,10 @@ def check_constraint_field_names(model) -> frozenset[str]:
def relation_required_before_save(field) -> bool:
return is_tenant_relation(field) or field.name in check_constraint_field_names(
getattr(field, "model", None)
return (
is_tenant_relation(field)
or is_module_relation(field)
or field.name in check_constraint_field_names(getattr(field, "model", None))
)
@@ -136,6 +145,13 @@ class PluginCompatibility:
raise
return obj.save(**kwargs)
@staticmethod
def prepare_initial_save(obj, *, is_new: bool):
if is_new and obj._meta.label_lower == "dcim.module":
# Module.save() otherwise replicates ModuleType components which are
# imported explicitly from the archive and may already exist.
obj._disable_replication = True
def release_unique_relation(self, obj, field_name: str, value):
field = obj._meta.get_field(field_name)
if value is None or not relation_should_be_deferred(field):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netbox-export"
version = "0.3.7"
version = "0.3.8"
description = "Portable ZIP export and import for scoped NetBox data"
readme = "README.md"
requires-python = ">=3.12"
+70 -1
View File
@@ -5,7 +5,11 @@ import pytest
from django.db import connection, models
from netbox_export.services import importer as importer_module
from netbox_export.services.importer import _field_kwargs, _find_existing
from netbox_export.services.importer import (
_field_kwargs,
_find_existing,
_identity_lookup,
)
from netbox_export.services.plugin_compat import PluginCompatibility
from netbox_export.services.references import ReferenceResolver
@@ -73,6 +77,27 @@ class RearPort(models.Model):
]
class Module(models.Model):
class Meta:
app_label = "dcim"
class ModuleBay(models.Model):
device = models.ForeignKey(Device, on_delete=models.CASCADE)
module = models.ForeignKey(Module, on_delete=models.CASCADE)
name = models.CharField(max_length=64)
class Meta:
app_label = "dcim"
class ModularRearPort(models.Model):
module = models.ForeignKey(Module, on_delete=models.SET_NULL, null=True)
class Meta:
app_label = "compat_tests"
def tenant_record():
return {
"fields": {},
@@ -132,6 +157,50 @@ def test_unresolved_relation_in_check_constraint_blocks_initial_save():
assert missing_required == []
def test_modular_component_waits_for_module_before_initial_save():
resolver = ReferenceResolver({}, [], lambda label: None)
record = {
"fields": {},
"relations": {"module": {"ref": "dcim.module:12"}},
}
kwargs, unresolved, unresolved_values, missing_required = _field_kwargs(
ModularRearPort,
record,
resolver,
tenant_required=False,
)
assert kwargs is None
assert unresolved == []
assert unresolved_values == []
assert missing_required == []
def test_module_bay_identity_includes_owning_module():
device = Device(pk=210)
module = Module(pk=12)
resolver = ReferenceResolver(
{
"dcim.device:210": device,
"dcim.module:12": module,
},
[],
lambda label: None,
)
record = {
"fields": {"name": "3-LC"},
"relations": {
"device": {"ref": "dcim.device:210"},
"module": {"ref": "dcim.module:12"},
},
}
lookup = _identity_lookup(ModuleBay, record, resolver)
assert lookup == {"device": device, "module": module, "name": "3-LC"}
def test_resolved_nullable_unique_relation_is_deferred():
address = UniqueAddress(pk=50)
resolver = ReferenceResolver(
+22
View File
@@ -7,6 +7,7 @@ from netbox_export.services.plugin_compat import (
MISSING_TENANT_MESSAGE,
PluginCompatibility,
check_constraint_field_names,
is_module_relation,
is_tenant_relation,
relation_required_before_save,
relation_should_be_deferred,
@@ -86,6 +87,27 @@ def test_identifies_tenant_relation():
assert is_tenant_relation(relation_field("site", "dcim.site")) is False
def test_identifies_module_component_relation():
assert is_module_relation(relation_field("module", "dcim.module")) is True
assert is_module_relation(relation_field("device", "dcim.device")) is False
def test_new_module_disables_automatic_component_replication():
module = SimpleNamespace(_meta=SimpleNamespace(label_lower="dcim.module"))
PluginCompatibility.prepare_initial_save(module, is_new=True)
assert module._disable_replication is True
def test_existing_module_keeps_normal_save_behavior():
module = SimpleNamespace(_meta=SimpleNamespace(label_lower="dcim.module"))
PluginCompatibility.prepare_initial_save(module, is_new=False)
assert not hasattr(module, "_disable_replication")
def test_identifies_relations_used_by_check_constraint():
constraint = models.CheckConstraint(
condition=(