fix: restore PostgreSQL range values during import
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)"
|
||||||
Reference in New Issue
Block a user