fix: prevent module component duplication during import
This commit is contained in:
@@ -125,6 +125,10 @@ Zielzuordnung wird dabei atomar gelöst und als Warnung protokolliert.
|
|||||||
Gespeicherte Importzuordnungen werden bei Wiederholungsimporten gegen den
|
Gespeicherte Importzuordnungen werden bei Wiederholungsimporten gegen den
|
||||||
aktuellen Fachschlüssel geprüft. Existiert das Objekt bereits unter diesem
|
aktuellen Fachschlüssel geprüft. Existiert das Objekt bereits unter diesem
|
||||||
Schlüssel, wird die Zuordnung korrigiert, statt ein Duplikat anzulegen.
|
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
|
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.7"
|
version = "0.3.8"
|
||||||
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.7",
|
"plugin_version": "0.3.8",
|
||||||
"scope": {
|
"scope": {
|
||||||
"type": scope_type,
|
"type": scope_type,
|
||||||
"source_pk": str(scope_id),
|
"source_pk": str(scope_id),
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ EXPLICIT_IDENTITIES = {
|
|||||||
"dcim.frontport": ("device", "name"),
|
"dcim.frontport": ("device", "name"),
|
||||||
"dcim.rearport": ("device", "name"),
|
"dcim.rearport": ("device", "name"),
|
||||||
"dcim.devicebay": ("device", "name"),
|
"dcim.devicebay": ("device", "name"),
|
||||||
"dcim.modulebay": ("device", "name"),
|
"dcim.modulebay": ("device", "module", "name"),
|
||||||
"dcim.inventoryitem": ("device", "parent", "name"),
|
"dcim.inventoryitem": ("device", "parent", "name"),
|
||||||
"ipam.prefix": ("vrf", "prefix"),
|
"ipam.prefix": ("vrf", "prefix"),
|
||||||
"ipam.ipaddress": ("vrf", "address"),
|
"ipam.ipaddress": ("vrf", "address"),
|
||||||
@@ -411,6 +411,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
|
|||||||
progressed = True
|
progressed = True
|
||||||
continue
|
continue
|
||||||
_set_files(obj, record, parsed.assets, saved_files, dry_run=dry_run)
|
_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)
|
compatibility.save(obj)
|
||||||
action = "updated" if existing is not None else "created"
|
action = "updated" if existing is not None else "created"
|
||||||
writable.add(record_id)
|
writable.add(record_id)
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ def is_tenant_relation(field) -> bool:
|
|||||||
return getattr(getattr(related_model, "_meta", None), "label_lower", None) == "tenancy.tenant"
|
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):
|
def _condition_field_names(condition):
|
||||||
for child in getattr(condition, "children", ()):
|
for child in getattr(condition, "children", ()):
|
||||||
if isinstance(child, tuple):
|
if isinstance(child, tuple):
|
||||||
@@ -47,8 +54,10 @@ def check_constraint_field_names(model) -> frozenset[str]:
|
|||||||
|
|
||||||
|
|
||||||
def relation_required_before_save(field) -> bool:
|
def relation_required_before_save(field) -> bool:
|
||||||
return is_tenant_relation(field) or field.name in check_constraint_field_names(
|
return (
|
||||||
getattr(field, "model", None)
|
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
|
raise
|
||||||
return obj.save(**kwargs)
|
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):
|
def release_unique_relation(self, obj, field_name: str, value):
|
||||||
field = obj._meta.get_field(field_name)
|
field = obj._meta.get_field(field_name)
|
||||||
if value is None or not relation_should_be_deferred(field):
|
if value is None or not relation_should_be_deferred(field):
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-export"
|
name = "netbox-export"
|
||||||
version = "0.3.7"
|
version = "0.3.8"
|
||||||
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"
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import pytest
|
|||||||
from django.db import connection, models
|
from django.db import connection, models
|
||||||
|
|
||||||
from netbox_export.services import importer as importer_module
|
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.plugin_compat import PluginCompatibility
|
||||||
from netbox_export.services.references import ReferenceResolver
|
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():
|
def tenant_record():
|
||||||
return {
|
return {
|
||||||
"fields": {},
|
"fields": {},
|
||||||
@@ -132,6 +157,50 @@ def test_unresolved_relation_in_check_constraint_blocks_initial_save():
|
|||||||
assert missing_required == []
|
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():
|
def test_resolved_nullable_unique_relation_is_deferred():
|
||||||
address = UniqueAddress(pk=50)
|
address = UniqueAddress(pk=50)
|
||||||
resolver = ReferenceResolver(
|
resolver = ReferenceResolver(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from netbox_export.services.plugin_compat import (
|
|||||||
MISSING_TENANT_MESSAGE,
|
MISSING_TENANT_MESSAGE,
|
||||||
PluginCompatibility,
|
PluginCompatibility,
|
||||||
check_constraint_field_names,
|
check_constraint_field_names,
|
||||||
|
is_module_relation,
|
||||||
is_tenant_relation,
|
is_tenant_relation,
|
||||||
relation_required_before_save,
|
relation_required_before_save,
|
||||||
relation_should_be_deferred,
|
relation_should_be_deferred,
|
||||||
@@ -86,6 +87,27 @@ def test_identifies_tenant_relation():
|
|||||||
assert is_tenant_relation(relation_field("site", "dcim.site")) is False
|
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():
|
def test_identifies_relations_used_by_check_constraint():
|
||||||
constraint = models.CheckConstraint(
|
constraint = models.CheckConstraint(
|
||||||
condition=(
|
condition=(
|
||||||
|
|||||||
Reference in New Issue
Block a user