fix: reassign unique imported relations atomically

This commit is contained in:
2026-08-05 13:56:16 +02:00
parent 3eb1729c93
commit f22276d68d
8 changed files with 134 additions and 4 deletions
+3
View File
@@ -119,6 +119,9 @@ verwendet oder neu angelegt. Der Importbericht weist darauf hin.
Beziehungen, die Teil einer Plugin-Datenbankprüfung sind, werden vollständig Beziehungen, die Teil einer Plugin-Datenbankprüfung sind, werden vollständig
aufgelöst, bevor das Objekt erstmals gespeichert wird. Dies betrifft unter aufgelöst, bevor das Objekt erstmals gespeichert wird. Dies betrifft unter
anderem die Plattformzuordnung von NetBox-SLM-Softwareinstallationen. 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.
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.5" version = "0.3.6"
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.5", "plugin_version": "0.3.6",
"scope": { "scope": {
"type": scope_type, "type": scope_type,
"source_pk": str(scope_id), "source_pk": str(scope_id),
+4
View File
@@ -20,6 +20,7 @@ from .plugin_compat import (
PluginCompatibility, PluginCompatibility,
is_tenant_relation, is_tenant_relation,
relation_required_before_save, relation_required_before_save,
relation_should_be_deferred,
) )
from .references import MISSING_REFERENCE, ReferenceResolver from .references import MISSING_REFERENCE, ReferenceResolver
@@ -256,6 +257,8 @@ def _field_kwargs(model, record, resolver, *, tenant_required: bool):
missing_required.append(name) missing_required.append(name)
elif value is None and tenant_relation and tenant_required: elif value is None and tenant_relation and tenant_required:
continue continue
elif value is not None and relation_should_be_deferred(field):
unresolved.append((name, spec))
else: else:
kwargs[name] = value kwargs[name] = value
elif field.null and not required_before_save: elif field.null and not required_before_save:
@@ -431,6 +434,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
if value is MISSING_REFERENCE: if value is MISSING_REFERENCE:
continue continue
obj = resolved[record_id] obj = resolved[record_id]
compatibility.release_unique_relation(obj, name, value)
setattr(obj, name, value) setattr(obj, name, value)
compatibility.save(obj, update_fields=[name]) compatibility.save(obj, update_fields=[name])
+23
View File
@@ -52,6 +52,10 @@ def relation_required_before_save(field) -> bool:
) )
def relation_should_be_deferred(field) -> bool:
return bool(field.null and field.unique and not relation_required_before_save(field))
class PluginCompatibility: class PluginCompatibility:
def __init__( def __init__(
self, self,
@@ -131,3 +135,22 @@ class PluginCompatibility:
if not self._is_missing_tenant_error(exc) or not self._assign_fallback_tenant(obj, force=True): if not self._is_missing_tenant_error(exc) or not self._assign_fallback_tenant(obj, force=True):
raise raise
return obj.save(**kwargs) return obj.save(**kwargs)
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):
return
conflicts = type(obj)._default_manager.filter(**{field.attname: value.pk})
if obj.pk is not None:
conflicts = conflicts.exclude(pk=obj.pk)
conflict_ids = list(conflicts.values_list("pk", flat=True))
if not conflict_ids:
return
type(obj)._default_manager.filter(pk__in=conflict_ids).update(**{field.attname: None})
identifiers = ", ".join(str(pk) for pk in conflict_ids)
self.warnings.append(
f"Eindeutige Referenz {obj._meta.label_lower}.{field_name} wurde von Zielobjekt(en) "
f"{identifiers} gelöst und dem importierten Objekt neu zugeordnet."
)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-export" name = "netbox-export"
version = "0.3.5" version = "0.3.6"
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"
+72 -1
View File
@@ -1,10 +1,14 @@
from typing import ClassVar from typing import ClassVar
from django.db import models import pytest
from django.db import connection, models
from netbox_export.services.importer import _field_kwargs from netbox_export.services.importer import _field_kwargs
from netbox_export.services.plugin_compat import PluginCompatibility
from netbox_export.services.references import ReferenceResolver from netbox_export.services.references import ReferenceResolver
pytestmark = pytest.mark.django_db(transaction=True)
class Tenant(models.Model): class Tenant(models.Model):
class Meta: class Meta:
@@ -36,6 +40,18 @@ class ConstrainedInstallation(models.Model):
] ]
class UniqueAddress(models.Model):
class Meta:
app_label = "compat_tests"
class UniqueAddressOwner(models.Model):
primary_ip6 = models.OneToOneField(UniqueAddress, on_delete=models.SET_NULL, null=True)
class Meta:
app_label = "compat_tests"
def tenant_record(): def tenant_record():
return { return {
"fields": {}, "fields": {},
@@ -93,3 +109,58 @@ def test_unresolved_relation_in_check_constraint_blocks_initial_save():
assert unresolved == [] assert unresolved == []
assert unresolved_values == [] assert unresolved_values == []
assert missing_required == [] assert missing_required == []
def test_resolved_nullable_unique_relation_is_deferred():
address = UniqueAddress(pk=50)
resolver = ReferenceResolver(
{"compat_tests.uniqueaddress:50": address},
[],
lambda label: None,
)
record = {
"fields": {},
"relations": {"primary_ip6": {"ref": "compat_tests.uniqueaddress:50"}},
}
kwargs, unresolved, unresolved_values, missing_required = _field_kwargs(
UniqueAddressOwner,
record,
resolver,
tenant_required=False,
)
assert kwargs == {}
assert unresolved == [("primary_ip6", {"ref": "compat_tests.uniqueaddress:50"})]
assert unresolved_values == []
assert missing_required == []
def test_existing_unique_relation_is_released_before_reassignment():
with connection.schema_editor() as schema_editor:
schema_editor.create_model(UniqueAddress)
schema_editor.create_model(UniqueAddressOwner)
try:
address = UniqueAddress.objects.create()
previous_owner = UniqueAddressOwner.objects.create(primary_ip6=address)
imported_owner = UniqueAddressOwner()
warnings = []
compatibility = PluginCompatibility(warnings, dry_run=False, tenant_required=False)
compatibility.release_unique_relation(imported_owner, "primary_ip6", address)
imported_owner.primary_ip6 = address
imported_owner.save()
previous_owner.refresh_from_db()
assert previous_owner.primary_ip6 is None
assert imported_owner.primary_ip6 == address
assert warnings == [
(
"Eindeutige Referenz compat_tests.uniqueaddressowner.primary_ip6 wurde von "
f"Zielobjekt(en) {previous_owner.pk} gelöst und dem importierten Objekt neu zugeordnet."
)
]
finally:
with connection.schema_editor() as schema_editor:
schema_editor.delete_model(UniqueAddressOwner)
schema_editor.delete_model(UniqueAddress)
+29
View File
@@ -9,6 +9,7 @@ from netbox_export.services.plugin_compat import (
check_constraint_field_names, check_constraint_field_names,
is_tenant_relation, is_tenant_relation,
relation_required_before_save, relation_required_before_save,
relation_should_be_deferred,
) )
@@ -101,6 +102,34 @@ def test_identifies_relations_used_by_check_constraint():
assert relation_required_before_save(field) is True assert relation_required_before_save(field) is True
def test_nullable_unique_relation_is_deferred_unless_required_by_constraint():
regular = relation_field("primary_ip6", "ipam.ipaddress")
regular.null = True
regular.unique = True
regular.model = type(
"VirtualMachine",
(),
{"_meta": SimpleNamespace(constraints=[])},
)
assert relation_should_be_deferred(regular) is True
constrained = relation_field("platform", "dcim.device")
constrained.null = True
constrained.unique = True
constraint = models.CheckConstraint(
condition=models.Q(platform__isnull=False),
name="platform_required",
)
constrained.model = type(
"Installation",
(),
{"_meta": SimpleNamespace(constraints=[constraint])},
)
assert relation_should_be_deferred(constrained) is False
def test_creates_and_assigns_auto_import_tenant_when_policy_is_active(): def test_creates_and_assigns_auto_import_tenant_when_policy_is_active():
manager = FakeTenantManager() manager = FakeTenantManager()
tenant_model = type("TenantModel", (), {"_default_manager": manager}) tenant_model = type("TenantModel", (), {"_default_manager": manager})