fix: tolerate missing external import references

This commit is contained in:
2026-08-05 13:09:47 +02:00
parent f5ad77b430
commit e061c2b02d
7 changed files with 236 additions and 63 deletions
+4 -2
View File
@@ -92,8 +92,10 @@ Import werden vorhandene Objekte über ihre eindeutigen Fachschlüssel erkannt.
## Verhalten und Grenzen ## Verhalten und Grenzen
- Quelle und Ziel müssen NetBox 4.6.x und dieselben Plugins/Modelle verwenden. - Quelle und Ziel müssen NetBox 4.6.x und dieselben Plugins/Modelle verwenden.
- Benutzerkonten und Berechtigungen werden nicht exportiert. Referenzen auf - Benutzerkonten und Berechtigungen werden nicht exportiert. Fehlende oder
Benutzer oder Gruppen müssen auf dem Ziel bereits eindeutig vorhanden sein. nicht eindeutige Referenzen auf Benutzer und Gruppen werden ausgelassen und
nach dem Import als Warnung angezeigt. Benötigt ein neuer Datensatz zwingend
eine solche Referenz, wird nur dieser Datensatz übersprungen.
- Der Import erstellt und aktualisiert Objekte. Zielobjekte, die im Archiv nicht - Der Import erstellt und aktualisiert Objekte. Zielobjekte, die im Archiv nicht
vorkommen, werden bewusst nicht gelöscht. vorkommen, werden bewusst nicht gelöscht.
- Fehlende Bilddateien werden im Archiv vermerkt, können aber nicht rekonstruiert - Fehlende Bilddateien werden im Archiv vermerkt, können aber nicht rekonstruiert
+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.0" version = "0.3.1"
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.0", "plugin_version": "0.3.1",
"scope": { "scope": {
"type": scope_type, "type": scope_type,
"source_pk": str(scope_id), "source_pk": str(scope_id),
+68 -56
View File
@@ -16,6 +16,7 @@ from netbox_export.models import ImportedObjectMapping
from .archive import ParsedArchive from .archive import ParsedArchive
from .codec import SKIP_FIELD_NAMES, decode_scalar, generic_foreign_keys from .codec import SKIP_FIELD_NAMES, decode_scalar, generic_foreign_keys
from .exceptions import ArchiveValidationError, ExportImportError, ImportConflictError from .exceptions import ArchiveValidationError, ExportImportError, ImportConflictError
from .references import MISSING_REFERENCE, ReferenceResolver
EXPLICIT_IDENTITIES = { EXPLICIT_IDENTITIES = {
"tenancy.tenantgroup": ("slug",), "tenancy.tenantgroup": ("slug",),
@@ -76,48 +77,27 @@ def _model_for(label: str):
return model return model
def _external_object(spec: dict): def _decode_archived_value(encoded, resolver):
model = _model_for(spec["model"])
lookup = {key: decode_scalar(value) for key, value in spec.get("lookup", {}).items()}
try:
return model._default_manager.get(**lookup)
except model.DoesNotExist as exc:
raise ArchiveValidationError(
f"Externe Referenz fehlt: {spec['model']} mit {lookup}."
) from exc
except model.MultipleObjectsReturned as exc:
raise ArchiveValidationError(
f"Externe Referenz ist nicht eindeutig: {spec['model']} mit {lookup}."
) from exc
def _resolve_reference(spec, resolved: dict[str, models.Model]):
if spec is None:
return None, True
if "ref" in spec:
return resolved.get(spec["ref"]), spec["ref"] in resolved
if "external" in spec:
return _external_object(spec["external"]), True
raise ArchiveValidationError("Eine Objektreferenz im Archiv ist ungültig.")
def _decode_archived_value(encoded, resolved):
if isinstance(encoded, dict) and encoded.get("$type") == "object_ref": if isinstance(encoded, dict) and encoded.get("$type") == "object_ref":
target, available = _resolve_reference(encoded.get("value"), resolved) target, available = resolver.resolve(encoded.get("value"))
if target is MISSING_REFERENCE:
return None, True
return (target.pk if available and target is not None else None), available return (target.pk if available and target is not None else None), available
if isinstance(encoded, dict) and encoded.get("$type") == "multiobject_ref": if isinstance(encoded, dict) and encoded.get("$type") == "multiobject_ref":
values = [] values = []
for spec in encoded.get("value", []): for spec in encoded.get("value", []):
target, available = _resolve_reference(spec, resolved) target, available = resolver.resolve(spec)
if not available: if not available:
return None, False return None, False
if target is MISSING_REFERENCE:
continue
values.append(target.pk) values.append(target.pk)
return values, True return values, True
if isinstance(encoded, dict) and "$type" not in encoded: if isinstance(encoded, dict) and "$type" not in encoded:
value = {} value = {}
all_available = True all_available = True
for key, item in encoded.items(): for key, item in encoded.items():
decoded, available = _decode_archived_value(item, resolved) decoded, available = _decode_archived_value(item, resolver)
value[key] = decoded value[key] = decoded
all_available &= available all_available &= available
return value, all_available return value, all_available
@@ -138,7 +118,7 @@ def _identity_candidates(model):
yield tuple(constraint.fields) yield tuple(constraint.fields)
def _identity_lookup(model, record, resolved): def _identity_lookup(model, record, resolver):
scalar_values = record.get("fields", {}) scalar_values = record.get("fields", {})
relation_values = record.get("relations", {}) relation_values = record.get("relations", {})
generic_values = record.get("generic_relations", {}) generic_values = record.get("generic_relations", {})
@@ -156,14 +136,20 @@ def _identity_lookup(model, record, resolved):
if name in scalar_values: if name in scalar_values:
value = decode_scalar(scalar_values[name]) value = decode_scalar(scalar_values[name])
elif name in relation_values: elif name in relation_values:
value, available = _resolve_reference(relation_values[name], resolved) value, available = resolver.resolve(relation_values[name])
if not available: if not available:
raise IdentityNotReady raise IdentityNotReady
if value is MISSING_REFERENCE:
usable = False
break
elif name in generic_storage: elif name in generic_storage:
value_type, spec = generic_storage[name] value_type, spec = generic_storage[name]
target, available = _resolve_reference(spec, resolved) target, available = resolver.resolve(spec)
if not available: if not available:
raise IdentityNotReady raise IdentityNotReady
if target is MISSING_REFERENCE:
usable = False
break
if target is None: if target is None:
value = None value = None
elif value_type == "content_type": elif value_type == "content_type":
@@ -197,11 +183,11 @@ def _mapped_object(source_instance: uuid.UUID, record: dict, model):
return None return None
def _find_existing(source_instance, model, record, resolved): def _find_existing(source_instance, model, record, resolver):
mapped = _mapped_object(source_instance, record, model) mapped = _mapped_object(source_instance, record, model)
if mapped is not None: if mapped is not None:
return mapped return mapped
lookup = _identity_lookup(model, record, resolved) lookup = _identity_lookup(model, record, resolver)
if not lookup: if not lookup:
return None return None
try: try:
@@ -224,11 +210,12 @@ def _write_mapping(source_instance, record, obj):
) )
def _field_kwargs(model, record, resolved): def _field_kwargs(model, record, resolver):
valid_fields = {field.name: field for field in model._meta.concrete_fields} valid_fields = {field.name: field for field in model._meta.concrete_fields}
kwargs = {} kwargs = {}
unresolved = [] unresolved = []
unresolved_values = [] unresolved_values = []
missing_required = []
for name, encoded in record.get("fields", {}).items(): for name, encoded in record.get("fields", {}).items():
field = valid_fields.get(name) field = valid_fields.get(name)
if ( if (
@@ -241,51 +228,59 @@ def _field_kwargs(model, record, resolved):
continue continue
if isinstance(field, (models.ForeignKey, models.OneToOneField)): if isinstance(field, (models.ForeignKey, models.OneToOneField)):
continue continue
value, available = _decode_archived_value(encoded, resolved) value, available = _decode_archived_value(encoded, resolver)
if available: if available:
kwargs[name] = value kwargs[name] = value
elif name in ("custom_field_data", "default"): elif name in ("custom_field_data", "default"):
kwargs[name] = value if value is not None else ({} if name == "custom_field_data" else None) kwargs[name] = value if value is not None else ({} if name == "custom_field_data" else None)
unresolved_values.append((name, encoded)) unresolved_values.append((name, encoded))
else: else:
return None, [], [] return None, [], [], []
for name, spec in record.get("relations", {}).items(): for name, spec in record.get("relations", {}).items():
field = valid_fields.get(name) field = valid_fields.get(name)
if not isinstance(field, (models.ForeignKey, models.OneToOneField)): if not isinstance(field, (models.ForeignKey, models.OneToOneField)):
continue continue
value, available = _resolve_reference(spec, resolved) value, available = resolver.resolve(spec)
if available: if available:
if value is MISSING_REFERENCE:
if not field.null and not field.has_default():
missing_required.append(name)
else:
kwargs[name] = value kwargs[name] = value
elif field.null: elif field.null:
kwargs[name] = None
unresolved.append((name, spec)) unresolved.append((name, spec))
else: else:
return None, [], [] return None, [], [], []
return kwargs, unresolved, unresolved_values return kwargs, unresolved, unresolved_values, missing_required
def _set_generic_relations(obj, record, resolved, *, allow_deferred: bool): def _set_generic_relations(obj, record, resolver, *, allow_deferred: bool):
fields = {field.name: field for field in generic_foreign_keys(type(obj))} fields = {field.name: field for field in generic_foreign_keys(type(obj))}
unresolved = [] unresolved = []
missing_required = []
for name, spec in record.get("generic_relations", {}).items(): for name, spec in record.get("generic_relations", {}).items():
field = fields.get(name) field = fields.get(name)
if not field: if not field:
continue continue
value, available = _resolve_reference(spec, resolved) value, available = resolver.resolve(spec)
if available: if available:
if value is MISSING_REFERENCE:
ct_field = obj._meta.get_field(field.ct_field)
id_field = obj._meta.get_field(field.fk_field)
if not ct_field.null or not id_field.null:
missing_required.append(name)
else:
setattr(obj, name, value) setattr(obj, name, value)
elif allow_deferred: elif allow_deferred:
ct_field = obj._meta.get_field(field.ct_field) ct_field = obj._meta.get_field(field.ct_field)
id_field = obj._meta.get_field(field.fk_field) id_field = obj._meta.get_field(field.fk_field)
if ct_field.null and id_field.null: if ct_field.null and id_field.null:
setattr(obj, field.ct_field, None)
setattr(obj, field.fk_field, None)
unresolved.append((name, spec)) unresolved.append((name, spec))
else: else:
return None return None
else: else:
return None return None
return unresolved return unresolved, missing_required
def _set_files(obj, record, assets, saved_files, *, dry_run: bool): def _set_files(obj, record, assets, saved_files, *, dry_run: bool):
@@ -308,7 +303,7 @@ def _cleanup_files(saved_files):
logger.warning("Could not remove rolled-back import file %s", name, exc_info=True) logger.warning("Could not remove rolled-back import file %s", name, exc_info=True)
def _apply_m2m(obj, record, resolved): def _apply_m2m(obj, record, resolver):
for name, specs in record.get("many_to_many", {}).items(): for name, specs in record.get("many_to_many", {}).items():
try: try:
manager = getattr(obj, name) manager = getattr(obj, name)
@@ -316,9 +311,11 @@ def _apply_m2m(obj, record, resolved):
continue continue
values = [] values = []
for spec in specs: for spec in specs:
value, available = _resolve_reference(spec, resolved) value, available = resolver.resolve(spec)
if not available: if not available:
raise ArchiveValidationError(f"M2M-Referenz für {record['id']} konnte nicht aufgelöst werden.") raise ArchiveValidationError(f"M2M-Referenz für {record['id']} konnte nicht aufgelöst werden.")
if value is MISSING_REFERENCE:
continue
values.append(value) values.append(value)
manager.set(values) manager.set(values)
@@ -347,6 +344,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
report = ImportReport(dry_run=dry_run, warnings=list(parsed.warnings)) report = ImportReport(dry_run=dry_run, warnings=list(parsed.warnings))
resolved = {} resolved = {}
resolver = ReferenceResolver(resolved, report.warnings, _model_for)
deferred_relations = [] deferred_relations = []
deferred_generic = [] deferred_generic = []
deferred_values = [] deferred_values = []
@@ -360,11 +358,13 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
progressed = False progressed = False
for record_id, record in list(pending.items()): for record_id, record in list(pending.items()):
model = _model_for(record["model"]) model = _model_for(record["model"])
kwargs, unresolved, unresolved_value_fields = _field_kwargs(model, record, resolved) kwargs, unresolved, unresolved_value_fields, missing_required = _field_kwargs(
model, record, resolver
)
if kwargs is None: if kwargs is None:
continue continue
try: try:
existing = _find_existing(source_instance, model, record, resolved) existing = _find_existing(source_instance, model, record, resolver)
except IdentityNotReady: except IdentityNotReady:
continue continue
if existing is not None and conflict_strategy == "fail": if existing is not None and conflict_strategy == "fail":
@@ -377,8 +377,16 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
obj = existing or model() obj = existing or model()
for name, value in kwargs.items(): for name, value in kwargs.items():
setattr(obj, name, value) setattr(obj, name, value)
generic_unresolved = _set_generic_relations(obj, record, resolved, allow_deferred=True) generic_result = _set_generic_relations(obj, record, resolver, allow_deferred=True)
if generic_unresolved is None: if generic_result is None:
continue
generic_unresolved, missing_generic = generic_result
missing_required.extend(missing_generic)
if missing_required and existing is None:
resolver.skip(record_id, record["model"], missing_required)
report.add(record["model"], "skipped")
pending.pop(record_id)
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)
obj.save() obj.save()
@@ -405,9 +413,11 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
for record_id, name, spec in deferred_relations: for record_id, name, spec in deferred_relations:
if record_id not in writable: if record_id not in writable:
continue continue
value, available = _resolve_reference(spec, resolved) value, available = resolver.resolve(spec)
if not available: if not available:
raise ArchiveValidationError(f"Referenz {name} für {record_id} konnte nicht aufgelöst werden.") raise ArchiveValidationError(f"Referenz {name} für {record_id} konnte nicht aufgelöst werden.")
if value is MISSING_REFERENCE:
continue
obj = resolved[record_id] obj = resolved[record_id]
setattr(obj, name, value) setattr(obj, name, value)
obj.save(update_fields=[name]) obj.save(update_fields=[name])
@@ -415,9 +425,11 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
for record_id, name, spec in deferred_generic: for record_id, name, spec in deferred_generic:
if record_id not in writable: if record_id not in writable:
continue continue
value, available = _resolve_reference(spec, resolved) value, available = resolver.resolve(spec)
if not available: if not available:
raise ArchiveValidationError(f"Generische Referenz {name} für {record_id} fehlt.") raise ArchiveValidationError(f"Generische Referenz {name} für {record_id} fehlt.")
if value is MISSING_REFERENCE:
continue
obj = resolved[record_id] obj = resolved[record_id]
setattr(obj, name, value) setattr(obj, name, value)
field = next(field for field in generic_foreign_keys(type(obj)) if field.name == name) field = next(field for field in generic_foreign_keys(type(obj)) if field.name == name)
@@ -426,7 +438,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
for record_id, name, encoded in deferred_values: for record_id, name, encoded in deferred_values:
if record_id not in writable: if record_id not in writable:
continue continue
value, available = _decode_archived_value(encoded, resolved) value, available = _decode_archived_value(encoded, resolver)
if not available: if not available:
raise ArchiveValidationError(f"Custom-Field-Referenz {name} für {record_id} fehlt.") raise ArchiveValidationError(f"Custom-Field-Referenz {name} für {record_id} fehlt.")
obj = resolved[record_id] obj = resolved[record_id]
@@ -435,7 +447,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
for record_id, record in records.items(): for record_id, record in records.items():
if record_id in writable: if record_id in writable:
_apply_m2m(resolved[record_id], record, resolved) _apply_m2m(resolved[record_id], record, resolver)
if dry_run: if dry_run:
transaction.set_rollback(True) transaction.set_rollback(True)
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from .codec import decode_scalar
from .exceptions import ArchiveValidationError
MISSING_REFERENCE = object()
class ReferenceResolver:
def __init__(self, resolved, warnings, model_loader):
self.resolved = resolved
self.warnings = warnings
self.model_loader = model_loader
self.skipped = set()
self._external_cache = {}
self._warning_keys = set()
def warn(self, key, message):
if key not in self._warning_keys:
self._warning_keys.add(key)
self.warnings.append(message)
def skip(self, record_id, model_label, field_names):
self.skipped.add(record_id)
fields = ", ".join(field_names)
self.warn(
("skipped", record_id),
f"{record_id} ({model_label}) wurde wegen fehlender Pflichtreferenz übersprungen: {fields}.",
)
def _external_object(self, spec):
cache_key = repr(spec)
if cache_key in self._external_cache:
return self._external_cache[cache_key]
model = self.model_loader(spec["model"])
lookup = {key: decode_scalar(value) for key, value in spec.get("lookup", {}).items()}
reason = None
try:
value = model._default_manager.get(**lookup)
except model.DoesNotExist:
value = MISSING_REFERENCE
reason = "nicht gefunden"
except model.MultipleObjectsReturned:
value = MISSING_REFERENCE
reason = "nicht eindeutig"
if value is MISSING_REFERENCE:
self.warn(
("external", cache_key),
f"Externe Referenz ausgelassen: {spec['model']} mit {lookup} ({reason}).",
)
self._external_cache[cache_key] = value
return value
def resolve(self, spec):
if spec is None:
return None, True
if "ref" in spec:
record_id = spec["ref"]
if record_id in self.skipped:
self.warn(
("internal", record_id),
f"Referenzen auf das übersprungene Objekt {record_id} wurden ausgelassen.",
)
return MISSING_REFERENCE, True
return self.resolved.get(record_id), record_id in self.resolved
if "external" in spec:
return self._external_object(spec["external"]), True
raise ArchiveValidationError("Eine Objektreferenz im Archiv ist ungültig.")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-export" name = "netbox-export"
version = "0.3.0" version = "0.3.1"
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"
+91
View File
@@ -0,0 +1,91 @@
from netbox_export.services.references import MISSING_REFERENCE, ReferenceResolver
class DoesNotExist(Exception):
pass
class MultipleObjectsReturned(Exception):
pass
class FakeManager:
def __init__(self, result):
self.result = result
self.calls = 0
def get(self, **lookup):
self.calls += 1
if isinstance(self.result, Exception):
raise self.result
return self.result
def fake_model(manager):
return type(
"FakeModel",
(),
{
"DoesNotExist": DoesNotExist,
"MultipleObjectsReturned": MultipleObjectsReturned,
"_default_manager": manager,
},
)
def test_missing_external_reference_is_omitted_and_warned_once():
manager = FakeManager(DoesNotExist())
model = fake_model(manager)
warnings = []
resolver = ReferenceResolver({}, warnings, lambda label: model)
spec = {"external": {"model": "users.user", "lookup": {"username": "lke"}}}
first, first_available = resolver.resolve(spec)
second, second_available = resolver.resolve(spec)
assert first is MISSING_REFERENCE
assert second is MISSING_REFERENCE
assert first_available is True
assert second_available is True
assert manager.calls == 1
assert warnings == [
"Externe Referenz ausgelassen: users.user mit {'username': 'lke'} (nicht gefunden)."
]
def test_ambiguous_external_reference_is_omitted():
manager = FakeManager(MultipleObjectsReturned())
model = fake_model(manager)
warnings = []
resolver = ReferenceResolver({}, warnings, lambda label: model)
value, available = resolver.resolve(
{"external": {"model": "users.group", "lookup": {"name": "NetBox"}}}
)
assert value is MISSING_REFERENCE
assert available is True
assert warnings == [
"Externe Referenz ausgelassen: users.group mit {'name': 'NetBox'} (nicht eindeutig)."
]
def test_reference_to_skipped_record_is_omitted_and_warned_once():
warnings = []
resolver = ReferenceResolver({}, warnings, lambda label: None)
resolver.skip("dcim.rackreservation:17", "dcim.rackreservation", ["user"])
first, first_available = resolver.resolve({"ref": "dcim.rackreservation:17"})
second, second_available = resolver.resolve({"ref": "dcim.rackreservation:17"})
assert first is MISSING_REFERENCE
assert second is MISSING_REFERENCE
assert first_available is True
assert second_available is True
assert warnings == [
(
"dcim.rackreservation:17 (dcim.rackreservation) wurde wegen fehlender "
"Pflichtreferenz übersprungen: user."
),
"Referenzen auf das übersprungene Objekt dcim.rackreservation:17 wurden ausgelassen.",
]