fix: derive image attachment dimensions during import

This commit is contained in:
2026-08-05 15:43:15 +02:00
parent 4ba41953c1
commit 80469ede34
6 changed files with 94 additions and 6 deletions
+3
View File
@@ -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** **Überspringen** lässt das importierte Gerät positionslos und **Import abbrechen**
meldet den Rackplatzkonflikt vor dem Datenbankfehler. Mehr-U- und meldet den Rackplatzkonflikt vor dem Datenbankfehler. Mehr-U- und
Full-Depth-Belegungen werden dabei berücksichtigt. 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 Auf Quelle und Ziel müssen jeweils dieselben Plugin-Versionen und Migrationen
installiert sein. Verschlüsselte Zugangsdaten von NetBox-VM-Import sind nur bei installiert sein. Verschlüsselte Zugangsdaten von NetBox-VM-Import sind nur bei
+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.9" version = "0.3.10"
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.9", "plugin_version": "0.3.10",
"scope": { "scope": {
"type": scope_type, "type": scope_type,
"source_pk": str(scope_id), "source_pk": str(scope_id),
+21 -3
View File
@@ -10,6 +10,7 @@ from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.images import get_image_dimensions
from django.db import IntegrityError, models, transaction from django.db import IntegrityError, models, transaction
from netbox_export.models import ImportedObjectMapping 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): def _set_files(obj, record, assets, saved_files, *, dry_run: bool):
if dry_run:
return
for name, spec in record.get("files", {}).items(): for name, spec in record.get("files", {}).items():
if not spec or "path" not in spec or spec["path"] not in assets: if not spec or "path" not in spec or spec["path"] not in assets:
continue continue
filename = spec.get("name", spec["path"]).replace("\\", "/").rsplit("/", 1)[-1] 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 = 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)) 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): def _cleanup_files(saved_files):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-export" name = "netbox-export"
version = "0.3.9" version = "0.3.10"
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"
+67
View File
@@ -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)