From f22276d68d8acb8a54da0cf0aec71cfe6692faca Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 5 Aug 2026 13:56:16 +0200 Subject: [PATCH] fix: reassign unique imported relations atomically --- README.md | 3 + netbox_export/__init__.py | 2 +- netbox_export/services/exporter.py | 2 +- netbox_export/services/importer.py | 4 ++ netbox_export/services/plugin_compat.py | 23 ++++++++ pyproject.toml | 2 +- tests/test_importer_plugin_compat.py | 73 ++++++++++++++++++++++++- tests/test_plugin_compat.py | 29 ++++++++++ 8 files changed, 134 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b9260cb..b344058 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,9 @@ verwendet oder neu angelegt. Der Importbericht weist darauf hin. Beziehungen, die Teil einer Plugin-Datenbankprüfung sind, werden vollständig aufgelöst, bevor das Objekt erstmals gespeichert wird. Dies betrifft unter 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 installiert sein. Verschlüsselte Zugangsdaten von NetBox-VM-Import sind nur bei diff --git a/netbox_export/__init__.py b/netbox_export/__init__.py index c38936e..134e43a 100644 --- a/netbox_export/__init__.py +++ b/netbox_export/__init__.py @@ -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.5" + version = "0.3.6" author = "NetBox Export contributors" base_url = "netbox-export" min_version = "4.6.0" diff --git a/netbox_export/services/exporter.py b/netbox_export/services/exporter.py index 9861015..6615abe 100644 --- a/netbox_export/services/exporter.py +++ b/netbox_export/services/exporter.py @@ -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.5", + "plugin_version": "0.3.6", "scope": { "type": scope_type, "source_pk": str(scope_id), diff --git a/netbox_export/services/importer.py b/netbox_export/services/importer.py index 308a640..5dea01a 100644 --- a/netbox_export/services/importer.py +++ b/netbox_export/services/importer.py @@ -20,6 +20,7 @@ from .plugin_compat import ( PluginCompatibility, is_tenant_relation, relation_required_before_save, + relation_should_be_deferred, ) from .references import MISSING_REFERENCE, ReferenceResolver @@ -256,6 +257,8 @@ def _field_kwargs(model, record, resolver, *, tenant_required: bool): missing_required.append(name) elif value is None and tenant_relation and tenant_required: continue + elif value is not None and relation_should_be_deferred(field): + unresolved.append((name, spec)) else: kwargs[name] = value 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: continue obj = resolved[record_id] + compatibility.release_unique_relation(obj, name, value) setattr(obj, name, value) compatibility.save(obj, update_fields=[name]) diff --git a/netbox_export/services/plugin_compat.py b/netbox_export/services/plugin_compat.py index dd5de13..4bf28c7 100644 --- a/netbox_export/services/plugin_compat.py +++ b/netbox_export/services/plugin_compat.py @@ -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: def __init__( self, @@ -131,3 +135,22 @@ class PluginCompatibility: if not self._is_missing_tenant_error(exc) or not self._assign_fallback_tenant(obj, force=True): raise 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." + ) diff --git a/pyproject.toml b/pyproject.toml index 1f00e47..2adfa51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-export" -version = "0.3.5" +version = "0.3.6" description = "Portable ZIP export and import for scoped NetBox data" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_importer_plugin_compat.py b/tests/test_importer_plugin_compat.py index 4f9b009..c0511f2 100644 --- a/tests/test_importer_plugin_compat.py +++ b/tests/test_importer_plugin_compat.py @@ -1,10 +1,14 @@ 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.plugin_compat import PluginCompatibility from netbox_export.services.references import ReferenceResolver +pytestmark = pytest.mark.django_db(transaction=True) + class Tenant(models.Model): 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(): return { "fields": {}, @@ -93,3 +109,58 @@ def test_unresolved_relation_in_check_constraint_blocks_initial_save(): assert unresolved == [] assert unresolved_values == [] 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) diff --git a/tests/test_plugin_compat.py b/tests/test_plugin_compat.py index 6839130..c3329f6 100644 --- a/tests/test_plugin_compat.py +++ b/tests/test_plugin_compat.py @@ -9,6 +9,7 @@ from netbox_export.services.plugin_compat import ( check_constraint_field_names, is_tenant_relation, 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 +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(): manager = FakeTenantManager() tenant_model = type("TenantModel", (), {"_default_manager": manager})