diff --git a/README.md b/README.md index 768ef80..b9260cb 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,9 @@ erzwungene Mandantenpflicht wird beim Import berücksichtigt: Das Zielobjekt wir erst gespeichert, nachdem sein Mandant importiert und zugeordnet wurde. Ist kein Mandant auflösbar, wird automatisch ein vorhandener Mandant `Auto-Import` 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. 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 23fc6b2..c38936e 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.4" + version = "0.3.5" 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 8b6b37c..9861015 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.4", + "plugin_version": "0.3.5", "scope": { "type": scope_type, "source_pk": str(scope_id), diff --git a/netbox_export/services/importer.py b/netbox_export/services/importer.py index 69b9d3d..308a640 100644 --- a/netbox_export/services/importer.py +++ b/netbox_export/services/importer.py @@ -19,6 +19,7 @@ from .exceptions import ArchiveValidationError, ExportImportError, ImportConflic from .plugin_compat import ( PluginCompatibility, is_tenant_relation, + relation_required_before_save, ) from .references import MISSING_REFERENCE, ReferenceResolver @@ -245,16 +246,19 @@ def _field_kwargs(model, record, resolver, *, tenant_required: bool): if not isinstance(field, (models.ForeignKey, models.OneToOneField)): continue tenant_relation = is_tenant_relation(field) + required_before_save = relation_required_before_save(field) value, available = resolver.resolve(spec) if available: if value is MISSING_REFERENCE: - if not field.null and not field.has_default(): + if (required_before_save and not tenant_relation) or ( + not field.null and not field.has_default() and not tenant_relation + ): missing_required.append(name) elif value is None and tenant_relation and tenant_required: continue else: kwargs[name] = value - elif field.null and not tenant_relation: + elif field.null and not required_before_save: unresolved.append((name, spec)) else: return None, [], [], [] diff --git a/netbox_export/services/plugin_compat.py b/netbox_export/services/plugin_compat.py index 9a7d667..dd5de13 100644 --- a/netbox_export/services/plugin_compat.py +++ b/netbox_export/services/plugin_compat.py @@ -1,7 +1,10 @@ from __future__ import annotations +from functools import cache + from django.apps import apps from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.db import models AUTO_IMPORT_TENANT_NAME = "Auto-Import" AUTO_IMPORT_TENANT_SLUG = "auto-import" @@ -24,6 +27,31 @@ def is_tenant_relation(field) -> bool: return getattr(getattr(related_model, "_meta", None), "label_lower", None) == "tenancy.tenant" +def _condition_field_names(condition): + for child in getattr(condition, "children", ()): + if isinstance(child, tuple): + yield child[0].split("__", 1)[0] + else: + yield from _condition_field_names(child) + + +@cache +def check_constraint_field_names(model) -> frozenset[str]: + if model is None: + return frozenset() + names = set() + for constraint in model._meta.constraints: + if isinstance(constraint, models.CheckConstraint): + names.update(_condition_field_names(constraint.condition)) + return frozenset(names) + + +def relation_required_before_save(field) -> bool: + return is_tenant_relation(field) or field.name in check_constraint_field_names( + getattr(field, "model", None) + ) + + class PluginCompatibility: def __init__( self, diff --git a/pyproject.toml b/pyproject.toml index a282721..1f00e47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-export" -version = "0.3.4" +version = "0.3.5" 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 6a28b7f..4f9b009 100644 --- a/tests/test_importer_plugin_compat.py +++ b/tests/test_importer_plugin_compat.py @@ -1,3 +1,5 @@ +from typing import ClassVar + from django.db import models from netbox_export.services.importer import _field_kwargs @@ -16,6 +18,24 @@ class TenantManagedObject(models.Model): app_label = "compat_tests" +class Platform(models.Model): + class Meta: + app_label = "compat_tests" + + +class ConstrainedInstallation(models.Model): + platform = models.ForeignKey(Platform, on_delete=models.PROTECT, null=True) + + class Meta: + app_label = "compat_tests" + constraints: ClassVar[list] = [ + models.CheckConstraint( + condition=models.Q(platform__isnull=False), + name="compat_tests_installation_platform", + ) + ] + + def tenant_record(): return { "fields": {}, @@ -53,3 +73,23 @@ def test_unresolved_tenant_also_blocks_when_policy_detection_is_unavailable(): assert unresolved == [] assert unresolved_values == [] assert missing_required == [] + + +def test_unresolved_relation_in_check_constraint_blocks_initial_save(): + resolver = ReferenceResolver({}, [], lambda label: None) + record = { + "fields": {}, + "relations": {"platform": {"ref": "compat_tests.platform:5"}}, + } + + kwargs, unresolved, unresolved_values, missing_required = _field_kwargs( + ConstrainedInstallation, + record, + resolver, + tenant_required=False, + ) + + assert kwargs is None + assert unresolved == [] + assert unresolved_values == [] + assert missing_required == [] diff --git a/tests/test_plugin_compat.py b/tests/test_plugin_compat.py index ba75f3d..6839130 100644 --- a/tests/test_plugin_compat.py +++ b/tests/test_plugin_compat.py @@ -1,11 +1,14 @@ from types import SimpleNamespace from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.db import models from netbox_export.services.plugin_compat import ( MISSING_TENANT_MESSAGE, PluginCompatibility, + check_constraint_field_names, is_tenant_relation, + relation_required_before_save, ) @@ -82,6 +85,22 @@ def test_identifies_tenant_relation(): assert is_tenant_relation(relation_field("site", "dcim.site")) is False +def test_identifies_relations_used_by_check_constraint(): + constraint = models.CheckConstraint( + condition=( + models.Q(device__isnull=False, virtualmachine__isnull=True) + | models.Q(device__isnull=True, virtualmachine__isnull=False) + ), + name="platform", + ) + model = type("ConstrainedModel", (), {"_meta": SimpleNamespace(constraints=[constraint])}) + field = relation_field("device", "dcim.device") + field.model = model + + assert check_constraint_field_names(model) == frozenset({"device", "virtualmachine"}) + assert relation_required_before_save(field) is True + + def test_creates_and_assigns_auto_import_tenant_when_policy_is_active(): manager = FakeTenantManager() tenant_model = type("TenantModel", (), {"_default_manager": manager})