diff --git a/README.md b/README.md index dbb46fd..bb96ea2 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,10 @@ Zielzuordnung wird dabei atomar gelöst und als Warnung protokolliert. Gespeicherte Importzuordnungen werden bei Wiederholungsimporten gegen den aktuellen Fachschlüssel geprüft. Existiert das Objekt bereits unter diesem Schlüssel, wird die Zuordnung korrigiert, statt ein Duplikat anzulegen. +Bei neuen NetBox-Modulen wird die automatische Komponentenreplikation +deaktiviert. Ports, Interfaces und Bays werden stattdessen ausschließlich aus +den Archivdatensätzen angelegt beziehungsweise vorhandenen Komponenten +zugeordnet. 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 93e6afb..1a34e37 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.7" + version = "0.3.8" 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 e19b354..40c21ae 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.7", + "plugin_version": "0.3.8", "scope": { "type": scope_type, "source_pk": str(scope_id), diff --git a/netbox_export/services/importer.py b/netbox_export/services/importer.py index a77cb6f..2c6a957 100644 --- a/netbox_export/services/importer.py +++ b/netbox_export/services/importer.py @@ -41,7 +41,7 @@ EXPLICIT_IDENTITIES = { "dcim.frontport": ("device", "name"), "dcim.rearport": ("device", "name"), "dcim.devicebay": ("device", "name"), - "dcim.modulebay": ("device", "name"), + "dcim.modulebay": ("device", "module", "name"), "dcim.inventoryitem": ("device", "parent", "name"), "ipam.prefix": ("vrf", "prefix"), "ipam.ipaddress": ("vrf", "address"), @@ -411,6 +411,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) + compatibility.prepare_initial_save(obj, is_new=existing is None) compatibility.save(obj) action = "updated" if existing is not None else "created" writable.add(record_id) diff --git a/netbox_export/services/plugin_compat.py b/netbox_export/services/plugin_compat.py index 4bf28c7..678c9af 100644 --- a/netbox_export/services/plugin_compat.py +++ b/netbox_export/services/plugin_compat.py @@ -27,6 +27,13 @@ def is_tenant_relation(field) -> bool: return getattr(getattr(related_model, "_meta", None), "label_lower", None) == "tenancy.tenant" +def is_module_relation(field) -> bool: + if field.name != "module": + return False + related_model = getattr(field.remote_field, "model", None) + return getattr(getattr(related_model, "_meta", None), "label_lower", None) == "dcim.module" + + def _condition_field_names(condition): for child in getattr(condition, "children", ()): if isinstance(child, tuple): @@ -47,8 +54,10 @@ def check_constraint_field_names(model) -> frozenset[str]: def relation_required_before_save(field) -> bool: - return is_tenant_relation(field) or field.name in check_constraint_field_names( - getattr(field, "model", None) + return ( + is_tenant_relation(field) + or is_module_relation(field) + or field.name in check_constraint_field_names(getattr(field, "model", None)) ) @@ -136,6 +145,13 @@ class PluginCompatibility: raise return obj.save(**kwargs) + @staticmethod + def prepare_initial_save(obj, *, is_new: bool): + if is_new and obj._meta.label_lower == "dcim.module": + # Module.save() otherwise replicates ModuleType components which are + # imported explicitly from the archive and may already exist. + obj._disable_replication = True + 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): diff --git a/pyproject.toml b/pyproject.toml index 5bbaf9d..dffc9d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-export" -version = "0.3.7" +version = "0.3.8" 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 0778568..a92fddc 100644 --- a/tests/test_importer_plugin_compat.py +++ b/tests/test_importer_plugin_compat.py @@ -5,7 +5,11 @@ import pytest from django.db import connection, models from netbox_export.services import importer as importer_module -from netbox_export.services.importer import _field_kwargs, _find_existing +from netbox_export.services.importer import ( + _field_kwargs, + _find_existing, + _identity_lookup, +) from netbox_export.services.plugin_compat import PluginCompatibility from netbox_export.services.references import ReferenceResolver @@ -73,6 +77,27 @@ class RearPort(models.Model): ] +class Module(models.Model): + class Meta: + app_label = "dcim" + + +class ModuleBay(models.Model): + device = models.ForeignKey(Device, on_delete=models.CASCADE) + module = models.ForeignKey(Module, on_delete=models.CASCADE) + name = models.CharField(max_length=64) + + class Meta: + app_label = "dcim" + + +class ModularRearPort(models.Model): + module = models.ForeignKey(Module, on_delete=models.SET_NULL, null=True) + + class Meta: + app_label = "compat_tests" + + def tenant_record(): return { "fields": {}, @@ -132,6 +157,50 @@ def test_unresolved_relation_in_check_constraint_blocks_initial_save(): assert missing_required == [] +def test_modular_component_waits_for_module_before_initial_save(): + resolver = ReferenceResolver({}, [], lambda label: None) + record = { + "fields": {}, + "relations": {"module": {"ref": "dcim.module:12"}}, + } + + kwargs, unresolved, unresolved_values, missing_required = _field_kwargs( + ModularRearPort, + record, + resolver, + tenant_required=False, + ) + + assert kwargs is None + assert unresolved == [] + assert unresolved_values == [] + assert missing_required == [] + + +def test_module_bay_identity_includes_owning_module(): + device = Device(pk=210) + module = Module(pk=12) + resolver = ReferenceResolver( + { + "dcim.device:210": device, + "dcim.module:12": module, + }, + [], + lambda label: None, + ) + record = { + "fields": {"name": "3-LC"}, + "relations": { + "device": {"ref": "dcim.device:210"}, + "module": {"ref": "dcim.module:12"}, + }, + } + + lookup = _identity_lookup(ModuleBay, record, resolver) + + assert lookup == {"device": device, "module": module, "name": "3-LC"} + + def test_resolved_nullable_unique_relation_is_deferred(): address = UniqueAddress(pk=50) resolver = ReferenceResolver( diff --git a/tests/test_plugin_compat.py b/tests/test_plugin_compat.py index c3329f6..bfd3f02 100644 --- a/tests/test_plugin_compat.py +++ b/tests/test_plugin_compat.py @@ -7,6 +7,7 @@ from netbox_export.services.plugin_compat import ( MISSING_TENANT_MESSAGE, PluginCompatibility, check_constraint_field_names, + is_module_relation, is_tenant_relation, relation_required_before_save, relation_should_be_deferred, @@ -86,6 +87,27 @@ def test_identifies_tenant_relation(): assert is_tenant_relation(relation_field("site", "dcim.site")) is False +def test_identifies_module_component_relation(): + assert is_module_relation(relation_field("module", "dcim.module")) is True + assert is_module_relation(relation_field("device", "dcim.device")) is False + + +def test_new_module_disables_automatic_component_replication(): + module = SimpleNamespace(_meta=SimpleNamespace(label_lower="dcim.module")) + + PluginCompatibility.prepare_initial_save(module, is_new=True) + + assert module._disable_replication is True + + +def test_existing_module_keeps_normal_save_behavior(): + module = SimpleNamespace(_meta=SimpleNamespace(label_lower="dcim.module")) + + PluginCompatibility.prepare_initial_save(module, is_new=False) + + assert not hasattr(module, "_disable_replication") + + def test_identifies_relations_used_by_check_constraint(): constraint = models.CheckConstraint( condition=(