diff --git a/README.md b/README.md index 729b40c..f8de105 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,9 @@ Belegungen löst **Aktualisieren** das Zielgerät mit Warnung von seiner Positio **Überspringen** lässt das importierte Gerät positionslos und **Import abbrechen** meldet den Rackplatzkonflikt vor dem Datenbankfehler. Mehr-U- und Full-Depth-Belegungen werden dabei berücksichtigt. +Bei Bildanhängen werden Breite und Höhe direkt aus der Bilddatei im Archiv +ermittelt. Dadurch sind die Pflichtfelder von NetBox auch im Prüflauf und bei +Dateispeichern ohne unmittelbaren Modell-Save gesetzt. 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 93713b7..ef8f098 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.9" + version = "0.3.10" 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 4e7b7e1..02a5eb0 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.9", + "plugin_version": "0.3.10", "scope": { "type": scope_type, "source_pk": str(scope_id), diff --git a/netbox_export/services/importer.py b/netbox_export/services/importer.py index 428357f..4a7a49c 100644 --- a/netbox_export/services/importer.py +++ b/netbox_export/services/importer.py @@ -10,6 +10,7 @@ from django.apps import apps from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.files.base import ContentFile +from django.core.files.images import get_image_dimensions from django.db import IntegrityError, models, transaction from netbox_export.models import ImportedObjectMapping @@ -462,15 +463,32 @@ def _set_generic_relations(obj, record, resolver, *, allow_deferred: bool): def _set_files(obj, record, assets, saved_files, *, dry_run: bool): - if dry_run: - return for name, spec in record.get("files", {}).items(): if not spec or "path" not in spec or spec["path"] not in assets: continue filename = spec.get("name", spec["path"]).replace("\\", "/").rsplit("/", 1)[-1] + field = obj._meta.get_field(name) + content = ContentFile(assets[spec["path"]]) + dimensions = None + if isinstance(field, models.ImageField): + dimensions = get_image_dimensions(content) + content.seek(0) + width, height = dimensions + if field.width_field and width is not None: + setattr(obj, field.width_field, width) + if field.height_field and height is not None: + setattr(obj, field.height_field, height) + if dry_run: + continue file_value = getattr(obj, name) - file_value.save(filename, ContentFile(assets[spec["path"]]), save=False) + file_value.save(filename, content, save=False) saved_files.append((file_value.storage, file_value.name)) + if dimensions is not None: + width, height = dimensions + if field.width_field and width is not None: + setattr(obj, field.width_field, width) + if field.height_field and height is not None: + setattr(obj, field.height_field, height) def _cleanup_files(saved_files): diff --git a/pyproject.toml b/pyproject.toml index ab3fd49..dce7b01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-export" -version = "0.3.9" +version = "0.3.10" description = "Portable ZIP export and import for scoped NetBox data" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_file_import.py b/tests/test_file_import.py new file mode 100644 index 0000000..b97aab6 --- /dev/null +++ b/tests/test_file_import.py @@ -0,0 +1,67 @@ +import io + +from django.core.files.storage import InMemoryStorage +from django.db import models +from PIL import Image + +from netbox_export.services.importer import _cleanup_files, _set_files + + +class ImageAsset(models.Model): + image = models.ImageField( + upload_to="test-images", + height_field="image_height", + width_field="image_width", + ) + image_height = models.PositiveSmallIntegerField() + image_width = models.PositiveSmallIntegerField() + + class Meta: + app_label = "file_import_tests" + + +def image_bytes(width=3, height=2): + output = io.BytesIO() + Image.new("RGB", (width, height), color="white").save(output, format="PNG") + return output.getvalue() + + +def image_record(): + return { + "files": { + "image": { + "path": "assets/extras.imageattachment_17/image/server-room.png", + "name": "image-attachments/location_2_Serverraum.jpg", + } + } + } + + +def test_dry_run_derives_required_image_dimensions_without_storing_file(): + obj = ImageAsset() + assets = {"assets/extras.imageattachment_17/image/server-room.png": image_bytes()} + saved_files = [] + + _set_files(obj, image_record(), assets, saved_files, dry_run=True) + + assert (obj.image_width, obj.image_height) == (3, 2) + assert not obj.image + assert saved_files == [] + + +def test_file_import_keeps_derived_dimensions_after_storage_save(monkeypatch): + field = ImageAsset._meta.get_field("image") + storage = InMemoryStorage() + monkeypatch.setattr(field, "storage", storage) + obj = ImageAsset() + assets = {"assets/extras.imageattachment_17/image/server-room.png": image_bytes(5, 4)} + saved_files = [] + + _set_files(obj, image_record(), assets, saved_files, dry_run=False) + + assert (obj.image_width, obj.image_height) == (5, 4) + assert storage.exists(obj.image.name) + assert saved_files == [(storage, obj.image.name)] + + _cleanup_files(saved_files) + assert not storage.exists(obj.image.name)