fix: assign fallback tenant during imports

This commit is contained in:
2026-08-05 13:39:18 +02:00
parent 7a018807d8
commit 985b66c96f
8 changed files with 231 additions and 34 deletions
+1 -1
View File
@@ -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.3",
"plugin_version": "0.3.4",
"scope": {
"type": scope_type,
"source_pk": str(scope_id),
+13 -13
View File
@@ -17,8 +17,8 @@ from .archive import ParsedArchive
from .codec import SKIP_FIELD_NAMES, decode_scalar, generic_foreign_keys
from .exceptions import ArchiveValidationError, ExportImportError, ImportConflictError
from .plugin_compat import (
netbox_utilities_tenant_required,
relation_required_before_save,
PluginCompatibility,
is_tenant_relation,
)
from .references import MISSING_REFERENCE, ReferenceResolver
@@ -244,17 +244,17 @@ def _field_kwargs(model, record, resolver, *, tenant_required: bool):
field = valid_fields.get(name)
if not isinstance(field, (models.ForeignKey, models.OneToOneField)):
continue
required_before_save = relation_required_before_save(field, tenant_required=tenant_required)
tenant_relation = is_tenant_relation(field)
value, available = resolver.resolve(spec)
if available:
if value is MISSING_REFERENCE:
if required_before_save or (not field.null and not field.has_default()):
if not field.null and not field.has_default():
missing_required.append(name)
elif value is None and required_before_save:
missing_required.append(name)
elif value is None and tenant_relation and tenant_required:
continue
else:
kwargs[name] = value
elif field.null and not required_before_save:
elif field.null and not tenant_relation:
unresolved.append((name, spec))
else:
return None, [], [], []
@@ -352,7 +352,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
report = ImportReport(dry_run=dry_run, warnings=list(parsed.warnings))
resolved = {}
resolver = ReferenceResolver(resolved, report.warnings, _model_for)
tenant_required = netbox_utilities_tenant_required()
compatibility = PluginCompatibility(report.warnings, dry_run=dry_run)
deferred_relations = []
deferred_generic = []
deferred_values = []
@@ -367,7 +367,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
for record_id, record in list(pending.items()):
model = _model_for(record["model"])
kwargs, unresolved, unresolved_value_fields, missing_required = _field_kwargs(
model, record, resolver, tenant_required=tenant_required
model, record, resolver, tenant_required=compatibility.tenant_required
)
if kwargs is None:
continue
@@ -397,7 +397,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
progressed = True
continue
_set_files(obj, record, parsed.assets, saved_files, dry_run=dry_run)
obj.save()
compatibility.save(obj)
action = "updated" if existing is not None else "created"
writable.add(record_id)
deferred_relations.extend((record_id, name, spec) for name, spec in unresolved)
@@ -428,7 +428,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
continue
obj = resolved[record_id]
setattr(obj, name, value)
obj.save(update_fields=[name])
compatibility.save(obj, update_fields=[name])
for record_id, name, spec in deferred_generic:
if record_id not in writable:
@@ -441,7 +441,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
obj = resolved[record_id]
setattr(obj, name, value)
field = next(field for field in generic_foreign_keys(type(obj)) if field.name == name)
obj.save(update_fields=[field.ct_field, field.fk_field])
compatibility.save(obj, update_fields=[field.ct_field, field.fk_field])
for record_id, name, encoded in deferred_values:
if record_id not in writable:
@@ -451,7 +451,7 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo
raise ArchiveValidationError(f"Custom-Field-Referenz {name} für {record_id} fehlt.")
obj = resolved[record_id]
setattr(obj, name, value)
obj.save(update_fields=[name])
compatibility.save(obj, update_fields=[name])
for record_id, record in records.items():
if record_id in writable:
+90 -2
View File
@@ -1,5 +1,12 @@
from __future__ import annotations
from django.apps import apps
from django.core.exceptions import FieldDoesNotExist, ValidationError
AUTO_IMPORT_TENANT_NAME = "Auto-Import"
AUTO_IMPORT_TENANT_SLUG = "auto-import"
MISSING_TENANT_MESSAGE = "Für dieses Objekt muss ein Mandant angegeben werden."
def netbox_utilities_tenant_required() -> bool:
"""Return the effective tenant policy without requiring the optional plugin."""
@@ -10,8 +17,89 @@ def netbox_utilities_tenant_required() -> bool:
return bool(tenant_required())
def relation_required_before_save(field, *, tenant_required: bool) -> bool:
if not tenant_required or field.name != "tenant":
def is_tenant_relation(field) -> bool:
if field.name != "tenant":
return False
related_model = getattr(field.remote_field, "model", None)
return getattr(getattr(related_model, "_meta", None), "label_lower", None) == "tenancy.tenant"
class PluginCompatibility:
def __init__(
self,
warnings,
*,
dry_run: bool,
tenant_required: bool | None = None,
tenant_model_loader=None,
):
self.warnings = warnings
self.dry_run = dry_run
self.tenant_required = (
netbox_utilities_tenant_required() if tenant_required is None else tenant_required
)
self.tenant_model_loader = tenant_model_loader or (lambda: apps.get_model("tenancy.tenant"))
self._fallback_tenant = None
@staticmethod
def _supports_tenant(obj) -> bool:
try:
field = obj._meta.get_field("tenant")
except FieldDoesNotExist:
return False
return is_tenant_relation(field)
@staticmethod
def _is_missing_tenant_error(exc: ValidationError) -> bool:
return MISSING_TENANT_MESSAGE in exc.messages
def _auto_import_tenant(self):
if self._fallback_tenant is not None:
return self._fallback_tenant
model = self.tenant_model_loader()
tenant = model._default_manager.filter(name=AUTO_IMPORT_TENANT_NAME).order_by("pk").first()
created = tenant is None
if tenant is None:
slug = AUTO_IMPORT_TENANT_SLUG
suffix = 2
while model._default_manager.filter(slug=slug).exists():
slug = f"{AUTO_IMPORT_TENANT_SLUG}-{suffix}"
suffix += 1
tenant = model._default_manager.create(name=AUTO_IMPORT_TENANT_NAME, slug=slug)
if self.dry_run:
message = (
'Für den Prüflauf wurde der Mandant "Auto-Import" automatisch als Ersatzmandant '
"verwendet; er wird nicht dauerhaft angelegt oder geändert."
)
elif created:
message = (
'Der Mandant "Auto-Import" wurde automatisch erstellt und Objekten ohne '
"auflösbaren Mandanten zugewiesen."
)
else:
message = (
'Der vorhandene Mandant "Auto-Import" wurde automatisch für Objekte ohne '
"auflösbaren Mandanten ausgewählt."
)
self.warnings.append(message)
self._fallback_tenant = tenant
return tenant
def _assign_fallback_tenant(self, obj, *, force: bool = False) -> bool:
if getattr(obj, "tenant_id", None) is not None or not self._supports_tenant(obj):
return False
if not force and not self.tenant_required:
return False
obj.tenant = self._auto_import_tenant()
return True
def save(self, obj, **kwargs):
self._assign_fallback_tenant(obj)
try:
return obj.save(**kwargs)
except ValidationError as exc:
if not self._is_missing_tenant_error(exc) or not self._assign_fallback_tenant(obj, force=True):
raise
return obj.save(**kwargs)