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
+29
View File
@@ -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,
+1 -1
View File
@@ -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),
+43 -2
View File
@@ -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))