diff --git a/README.md b/README.md index de3fd83..a4f36e1 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,9 @@ Import stößt das Plugin für alle enthaltenen Kabel die NetBox-eigene Neuberechnung der Kabelpfade an. Für diese Korrektur muss mit Plugin-Version `0.3.12` oder neuer ein neues Archiv auf der Quellinstanz erzeugt werden, da ältere Archive keine Portzuordnungen enthalten. +PostgreSQL-Range-Felder, darunter die erlaubten VLAN-ID-Bereiche einer +VLAN-Gruppe, werden typisiert im Archiv gespeichert. Der Import erkennt auch +die von älteren Plugin-Versionen als Text exportierten Range-Werte. 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. Bilder oberhalb des in diff --git a/netbox_export/__init__.py b/netbox_export/__init__.py index 4b40022..000efa1 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.12" + version = "0.3.13" author = "NetBox Export contributors" base_url = "netbox-export" min_version = "4.6.0" diff --git a/netbox_export/services/codec.py b/netbox_export/services/codec.py index 4685869..33953df 100644 --- a/netbox_export/services/codec.py +++ b/netbox_export/services/codec.py @@ -28,6 +28,19 @@ def model_label(model) -> str: return model._meta.label_lower +def is_range_value(value) -> bool: + return all( + hasattr(value, attribute) + for attribute in ("lower", "upper", "lower_inc", "upper_inc", "isempty") + ) + + +def _make_range(lower, upper, bounds, empty): + from django.db.backends.postgresql.psycopg_any import Range + + return Range(lower, upper, bounds, empty=empty) + + def encode_scalar(value): if value is None or isinstance(value, (bool, int, float, str)): return value @@ -45,6 +58,15 @@ def encode_scalar(value): return {"$type": "duration", "value": value.total_seconds()} if isinstance(value, bytes): return {"$type": "bytes", "value": base64.b64encode(value).decode("ascii")} + if is_range_value(value): + bounds = ("[" if value.lower_inc else "(") + ("]" if value.upper_inc else ")") + return { + "$type": "range", + "lower": encode_scalar(value.lower), + "upper": encode_scalar(value.upper), + "bounds": bounds, + "empty": value.isempty, + } if isinstance(value, (list, tuple)): return [encode_scalar(item) for item in value] if isinstance(value, dict): @@ -61,6 +83,13 @@ def decode_scalar(value): return value kind = value["$type"] raw = value.get("value") + if kind == "range": + return _make_range( + decode_scalar(value.get("lower")), + decode_scalar(value.get("upper")), + value.get("bounds", "[)"), + bool(value.get("empty", False)), + ) decoders = { "decimal": decimal.Decimal, "uuid": uuid.UUID, diff --git a/netbox_export/services/exporter.py b/netbox_export/services/exporter.py index b5634f4..b1d4f83 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.12", + "plugin_version": "0.3.13", "scope": { "type": scope_type, "source_pk": str(scope_id), diff --git a/netbox_export/services/importer.py b/netbox_export/services/importer.py index acb618d..c3c0671 100644 --- a/netbox_export/services/importer.py +++ b/netbox_export/services/importer.py @@ -1,8 +1,10 @@ from __future__ import annotations import io +import json import logging import math +import re import threading import uuid import warnings @@ -20,7 +22,7 @@ from PIL import Image as PillowImage from netbox_export.models import ImportedObjectMapping from .archive import ParsedArchive -from .codec import SKIP_FIELD_NAMES, decode_scalar, generic_foreign_keys +from .codec import SKIP_FIELD_NAMES, decode_scalar, generic_foreign_keys, is_range_value from .exceptions import ArchiveValidationError, ExportImportError, ImportConflictError from .plugin_compat import ( PluginCompatibility, @@ -62,6 +64,7 @@ NETBOX_IMAGE_MAX_PIXELS = 25_000_000 IMPORTED_IMAGE_TARGET_PIXELS = 20_000_000 IMPORTED_IMAGE_SOURCE_MAX_PIXELS = 100_000_000 _IMAGE_LIMIT_LOCK = threading.Lock() +_LEGACY_RANGE_PATTERN = re.compile(r"^([\[(])(.*),(.*)([\])])$") class IdentityNotReady(Exception): @@ -131,6 +134,44 @@ def _decode_archived_value(encoded, resolver): return decode_scalar(encoded), True +def _range_endpoint(field, value): + value = value.strip() + if not value: + return None + if value.startswith('"') and value.endswith('"'): + value = json.loads(value) + return field.base_field.to_python(value) + + +def _coerce_range_value(field, value): + if value is None or is_range_value(value): + return value + if isinstance(value, (list, tuple)) and len(value) == 2: + return field.range_type(value[0], value[1]) + if value == "empty": + return field.range_type(empty=True) + if not isinstance(value, str) or not (match := _LEGACY_RANGE_PATTERN.fullmatch(value.strip())): + return value + bounds = match.group(1) + match.group(4) + lower = _range_endpoint(field, match.group(2)) + upper = _range_endpoint(field, match.group(3)) + return field.range_type(lower, upper, bounds=bounds) + + +def _coerce_model_field_value(field, value): + if hasattr(field, "range_type") and hasattr(field, "base_field"): + return _coerce_range_value(field, value) + base_field = getattr(field, "base_field", None) + if ( + isinstance(value, (list, tuple)) + and base_field is not None + and hasattr(base_field, "range_type") + and hasattr(base_field, "base_field") + ): + return [_coerce_range_value(base_field, item) for item in value] + return value + + def _identity_candidates(model): explicit = EXPLICIT_IDENTITIES.get(model._meta.label_lower) if explicit: @@ -465,7 +506,7 @@ def _field_kwargs(model, record, resolver, *, tenant_required: bool): continue value, available = _decode_archived_value(encoded, resolver) if available: - kwargs[name] = value + kwargs[name] = _coerce_model_field_value(field, value) elif name in ("custom_field_data", "default"): kwargs[name] = value if value is not None else ({} if name == "custom_field_data" else None) unresolved_values.append((name, encoded)) diff --git a/pyproject.toml b/pyproject.toml index 444c4b2..55702a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-export" -version = "0.3.12" +version = "0.3.13" description = "Portable ZIP export and import for scoped NetBox data" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_codec.py b/tests/test_codec.py index 8534da1..cdf2e5a 100644 --- a/tests/test_codec.py +++ b/tests/test_codec.py @@ -1,10 +1,34 @@ import datetime import decimal import uuid +from dataclasses import dataclass +import pytest + +from netbox_export.services import codec from netbox_export.services.codec import decode_scalar, encode_scalar +@dataclass +class FakeRange: + lower: object = None + upper: object = None + bounds: str = "[)" + empty: bool = False + + @property + def lower_inc(self): + return not self.empty and self.bounds[0] == "[" + + @property + def upper_inc(self): + return not self.empty and self.bounds[1] == "]" + + @property + def isempty(self): + return self.empty + + def test_scalar_round_trip(): value = { "decimal": decimal.Decimal("12.340"), @@ -17,3 +41,31 @@ def test_scalar_round_trip(): assert decode_scalar(encode_scalar(value)) == value + +def test_postgresql_range_round_trip(monkeypatch): + monkeypatch.setattr( + codec, + "_make_range", + lambda lower, upper, bounds, empty: FakeRange(lower, upper, bounds, empty), + ) + value = FakeRange(1, 56, "[)") + + encoded = encode_scalar(value) + + assert encoded == { + "$type": "range", + "lower": 1, + "upper": 56, + "bounds": "[)", + "empty": False, + } + assert decode_scalar(encoded) == value + + +def test_real_psycopg_range_round_trip(): + pytest.importorskip("psycopg") + from django.db.backends.postgresql.psycopg_any import NumericRange + + value = NumericRange(1, 56, "[)") + + assert decode_scalar(encode_scalar(value)) == value diff --git a/tests/test_range_import.py b/tests/test_range_import.py new file mode 100644 index 0000000..714dc70 --- /dev/null +++ b/tests/test_range_import.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass + +from netbox_export.services.importer import _coerce_model_field_value + + +@dataclass +class FakeRange: + lower: object = None + upper: object = None + bounds: str = "[)" + empty: bool = False + + @property + def lower_inc(self): + return not self.empty and self.bounds[0] == "[" + + @property + def upper_inc(self): + return not self.empty and self.bounds[1] == "]" + + @property + def isempty(self): + return self.empty + + +class IntegerField: + @staticmethod + def to_python(value): + return int(value) + + +class IntegerRangeField: + base_field = IntegerField() + range_type = FakeRange + + +class ArrayField: + base_field = IntegerRangeField() + + +def test_legacy_string_ranges_are_restored_for_range_array_fields(): + value = ["[1, 56)", "(100, 200]", "empty"] + + converted = _coerce_model_field_value(ArrayField(), value) + + assert converted == [ + FakeRange(1, 56, "[)"), + FakeRange(100, 200, "(]"), + FakeRange(empty=True), + ] + + +def test_range_like_strings_remain_unchanged_for_normal_fields(): + field = object() + + assert _coerce_model_field_value(field, "[1, 56)") == "[1, 56)"