fix: restore PostgreSQL range values during import

This commit is contained in:
2026-09-02 11:11:43 +02:00
parent e44e27d307
commit d183334104
8 changed files with 186 additions and 5 deletions
+3
View File
@@ -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 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 `0.3.12` oder neuer ein neues Archiv auf der Quellinstanz erzeugt werden, da
ältere Archive keine Portzuordnungen enthalten. ä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 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 ermittelt. Dadurch sind die Pflichtfelder von NetBox auch im Prüflauf und bei
Dateispeichern ohne unmittelbaren Modell-Save gesetzt. Bilder oberhalb des in Dateispeichern ohne unmittelbaren Modell-Save gesetzt. Bilder oberhalb des in
+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.12" version = "0.3.13"
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"
+29
View File
@@ -28,6 +28,19 @@ def model_label(model) -> str:
return model._meta.label_lower 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): def encode_scalar(value):
if value is None or isinstance(value, (bool, int, float, str)): if value is None or isinstance(value, (bool, int, float, str)):
return value return value
@@ -45,6 +58,15 @@ def encode_scalar(value):
return {"$type": "duration", "value": value.total_seconds()} return {"$type": "duration", "value": value.total_seconds()}
if isinstance(value, bytes): if isinstance(value, bytes):
return {"$type": "bytes", "value": base64.b64encode(value).decode("ascii")} 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)): if isinstance(value, (list, tuple)):
return [encode_scalar(item) for item in value] return [encode_scalar(item) for item in value]
if isinstance(value, dict): if isinstance(value, dict):
@@ -61,6 +83,13 @@ def decode_scalar(value):
return value return value
kind = value["$type"] kind = value["$type"]
raw = value.get("value") 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 = { decoders = {
"decimal": decimal.Decimal, "decimal": decimal.Decimal,
"uuid": uuid.UUID, "uuid": uuid.UUID,
+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.12", "plugin_version": "0.3.13",
"scope": { "scope": {
"type": scope_type, "type": scope_type,
"source_pk": str(scope_id), "source_pk": str(scope_id),
+43 -2
View File
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import io import io
import json
import logging import logging
import math import math
import re
import threading import threading
import uuid import uuid
import warnings import warnings
@@ -20,7 +22,7 @@ from PIL import Image as PillowImage
from netbox_export.models import ImportedObjectMapping from netbox_export.models import ImportedObjectMapping
from .archive import ParsedArchive 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 .exceptions import ArchiveValidationError, ExportImportError, ImportConflictError
from .plugin_compat import ( from .plugin_compat import (
PluginCompatibility, PluginCompatibility,
@@ -62,6 +64,7 @@ NETBOX_IMAGE_MAX_PIXELS = 25_000_000
IMPORTED_IMAGE_TARGET_PIXELS = 20_000_000 IMPORTED_IMAGE_TARGET_PIXELS = 20_000_000
IMPORTED_IMAGE_SOURCE_MAX_PIXELS = 100_000_000 IMPORTED_IMAGE_SOURCE_MAX_PIXELS = 100_000_000
_IMAGE_LIMIT_LOCK = threading.Lock() _IMAGE_LIMIT_LOCK = threading.Lock()
_LEGACY_RANGE_PATTERN = re.compile(r"^([\[(])(.*),(.*)([\])])$")
class IdentityNotReady(Exception): class IdentityNotReady(Exception):
@@ -131,6 +134,44 @@ def _decode_archived_value(encoded, resolver):
return decode_scalar(encoded), True 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): def _identity_candidates(model):
explicit = EXPLICIT_IDENTITIES.get(model._meta.label_lower) explicit = EXPLICIT_IDENTITIES.get(model._meta.label_lower)
if explicit: if explicit:
@@ -465,7 +506,7 @@ def _field_kwargs(model, record, resolver, *, tenant_required: bool):
continue continue
value, available = _decode_archived_value(encoded, resolver) value, available = _decode_archived_value(encoded, resolver)
if available: if available:
kwargs[name] = value kwargs[name] = _coerce_model_field_value(field, value)
elif name in ("custom_field_data", "default"): elif name in ("custom_field_data", "default"):
kwargs[name] = value if value is not None else ({} if name == "custom_field_data" else None) kwargs[name] = value if value is not None else ({} if name == "custom_field_data" else None)
unresolved_values.append((name, encoded)) unresolved_values.append((name, encoded))
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-export" name = "netbox-export"
version = "0.3.12" version = "0.3.13"
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"
+52
View File
@@ -1,10 +1,34 @@
import datetime import datetime
import decimal import decimal
import uuid import uuid
from dataclasses import dataclass
import pytest
from netbox_export.services import codec
from netbox_export.services.codec import decode_scalar, encode_scalar 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(): def test_scalar_round_trip():
value = { value = {
"decimal": decimal.Decimal("12.340"), "decimal": decimal.Decimal("12.340"),
@@ -17,3 +41,31 @@ def test_scalar_round_trip():
assert decode_scalar(encode_scalar(value)) == value 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
+56
View File
@@ -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)"