fix: reassign unique imported relations atomically
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user