From 503b3817497cc06df357187ef005a0cfc898520d Mon Sep 17 00:00:00 2001 From: Sven Eberth Date: Wed, 29 Jul 2026 20:09:52 +0200 Subject: [PATCH 1/3] fix: keep order/cart address copies live until frozen `OrderSkel.billing_address` and `CartNodeSkel.shipping_address` used `updateLevel=OnValueAssignment` to persist the address as a copy, but that froze the copy already at assignment time: edits made to the referenced address during an ongoing checkout (adding a birthdate in a later step, correcting the address) were never mirrored onto the order/cart, and `OnValueAssignment` additionally turned the existing `refresh_*` helpers into no-ops. Add `SnapshotRelationalBone`, which keeps the copy in sync (like `Always`) while the owning skeleton is editable and freezes it via a `refresh()` no-op once the skeleton is frozen (`is_ordered` for the order, `is_frozen` for the cart). This preserves the snapshot for completed orders while reflecting live edits until checkout completes. Also guard `refresh_shipping_address` against sub-node skeletons that don't carry the `shipping_address` bone. --- src/viur/shop/skeletons/_bones.py | 61 +++++++++++++++++++++++++++++++ src/viur/shop/skeletons/cart.py | 10 +++-- src/viur/shop/skeletons/order.py | 7 ++-- 3 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 src/viur/shop/skeletons/_bones.py diff --git a/src/viur/shop/skeletons/_bones.py b/src/viur/shop/skeletons/_bones.py new file mode 100644 index 0000000..38c7d65 --- /dev/null +++ b/src/viur/shop/skeletons/_bones.py @@ -0,0 +1,61 @@ +import typing as t + +from viur.core.bones import RelationalBone, RelationalUpdateLevel +from viur.core.skeleton import SkeletonInstance + +from ..globals import SHOP_LOGGER + +logger = SHOP_LOGGER.getChild(__name__) + + +class SnapshotRelationalBone(RelationalBone): + """A :class:`RelationalBone` that keeps its cached ``refKeys`` copy in sync + while the owning skeleton is still editable, and freezes that copy once the + owning skeleton is frozen. + + Background + ---------- + viur-core only offers a *static* :class:`RelationalUpdateLevel`, and neither + value fits an order/cart address: + + - ``Always`` keeps the cached copy in sync with the referenced entity, but a + completed (frozen) order would then change retroactively whenever the + referenced address gets edited later. + - ``OnValueAssignment`` freezes the copy at assignment time, but then never + reflects edits made to the referenced entity during an ongoing checkout + (e.g. the customer adds a birthdate in a later step or corrects the + address) -- the stale copy is what gets persisted with the order. + + This bone combines both: it is configured with ``updateLevel=Always`` so the + copy is kept in sync -- both by the ``update_relations`` task (triggered when + the referenced entity changes) and by explicit :meth:`refresh` calls -- as + long as the owning skeleton is *not* frozen, and turns :meth:`refresh` into a + no-op once it *is* frozen, preserving the snapshot captured at freeze time. + + :param is_frozen: Callable deciding whether the owning skeleton is frozen and + its snapshot must be preserved. Defaults to reading the boolean + ``is_frozen`` bone. + """ + + def __init__( + self, + *args: t.Any, + is_frozen: t.Callable[[SkeletonInstance], bool] = lambda skel: bool(skel["is_frozen"]), + **kwargs: t.Any, + ) -> None: + # The live-sync behaviour relies on updateLevel=Always: it keeps the + # relation in the update_relations query and prevents refresh() from + # short-circuiting. Freezing is handled by refresh() below instead, so + # any caller-provided updateLevel would break the contract and is + # therefore overridden here. + kwargs["updateLevel"] = RelationalUpdateLevel.Always + super().__init__(*args, **kwargs) + self.is_frozen = is_frozen + + def refresh(self, skel: SkeletonInstance, name: str) -> None: + """Refresh the cached copy -- unless the owning skeleton is frozen, in + which case the snapshot captured at freeze time is preserved.""" + if self.is_frozen(skel): + logger.debug(f"Skipping refresh of frozen {name!r} on {skel['key']!r}") + return + super().refresh(skel, name) diff --git a/src/viur/shop/skeletons/cart.py b/src/viur/shop/skeletons/cart.py index 47aa29c..2517a84 100644 --- a/src/viur/shop/skeletons/cart.py +++ b/src/viur/shop/skeletons/cart.py @@ -8,6 +8,7 @@ from viur.core.skeleton import SkeletonInstance from viur.shop.types import * +from ._bones import SnapshotRelationalBone from .vat import VatIncludedSkel from ..globals import SHOP_INSTANCE, SHOP_LOGGER from ..skeletons.article import ArticleAbstractSkel @@ -320,14 +321,14 @@ class CartNodeSkel(TreeSkel): defaultValue=0, ) - shipping_address = RelationalBone( + shipping_address = SnapshotRelationalBone( kind="{{viur_shop_modulename}}_address", module="{{viur_shop_modulename}}/address", - # keep shipping address persistent: - updateLevel=RelationalUpdateLevel.OnValueAssignment, refKeys={ "*", }, + # keep the copy in sync while the cart is open, freeze it once frozen + # (default is_frozen reads the CartNodeSkel.is_frozen bone): ) customer_comment = TextBone( @@ -412,6 +413,9 @@ def refresh_shipping_address(cls, skel: SkeletonInstance) -> SkeletonInstance: Due to race-condition and timing issues, the dest values are not always set correctly. This refresh fixes this. """ + # Sub-node / total sub-skeletons don't carry the shipping_address bone. + if "shipping_address" not in skel: + return skel try: skel.shipping_address.refresh(skel, skel.shipping_address.name) except Exception as exc: diff --git a/src/viur/shop/skeletons/order.py b/src/viur/shop/skeletons/order.py index 558da01..71697ac 100644 --- a/src/viur/shop/skeletons/order.py +++ b/src/viur/shop/skeletons/order.py @@ -4,6 +4,7 @@ from viur.core.bones import * from viur.core.skeleton import Skeleton, SkeletonInstance from viur.shop.types import * +from ._bones import SnapshotRelationalBone from ..globals import SHOP_INSTANCE, SHOP_LOGGER logger = SHOP_LOGGER.getChild(__name__) @@ -19,16 +20,16 @@ def get_payment_providers() -> dict[str, str | translate]: class OrderSkel(Skeleton): kindName = "{{viur_shop_modulename}}_order" - billing_address = RelationalBone( + billing_address = SnapshotRelationalBone( kind="{{viur_shop_modulename}}_address", module="{{viur_shop_modulename}}/address", searchable=True, - # keep billing address persistent: - updateLevel=RelationalUpdateLevel.OnValueAssignment, # keep all fields of the billing address as a copy: refKeys={ "*", }, + # keep the copy in sync while the order is open, freeze it once ordered: + is_frozen=lambda skel: bool(skel["is_ordered"]), ) customer = RelationalBone( From 8d93660e5c6724b382f3b1a2ef6138444d2c5ac9 Mon Sep 17 00:00:00 2001 From: Sven Eberth Date: Thu, 30 Jul 2026 01:06:06 +0200 Subject: [PATCH 2/3] Enforce RelationalUpdateLevel.Always Co-authored-by: Jan Max Meyer --- src/viur/shop/skeletons/_bones.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/viur/shop/skeletons/_bones.py b/src/viur/shop/skeletons/_bones.py index 38c7d65..35dffae 100644 --- a/src/viur/shop/skeletons/_bones.py +++ b/src/viur/shop/skeletons/_bones.py @@ -41,15 +41,13 @@ def __init__( self, *args: t.Any, is_frozen: t.Callable[[SkeletonInstance], bool] = lambda skel: bool(skel["is_frozen"]), + updateLevel: RelationalUpdateLevel = RelationalUpdateLevel.Always **kwargs: t.Any, ) -> None: - # The live-sync behaviour relies on updateLevel=Always: it keeps the - # relation in the update_relations query and prevents refresh() from - # short-circuiting. Freezing is handled by refresh() below instead, so - # any caller-provided updateLevel would break the contract and is - # therefore overridden here. - kwargs["updateLevel"] = RelationalUpdateLevel.Always - super().__init__(*args, **kwargs) + # The live-sync behaviour relies on updateLevel=Always + if updateLevel != RelationalUpdateLevel.Always: + raise ValueError("SnapshotRelationalBone only accepts RelationalUpdateLevel.Always") + super().__init__(updateLevel=updateLevel, *args, **kwargs) self.is_frozen = is_frozen def refresh(self, skel: SkeletonInstance, name: str) -> None: From 6d74f74bb699c34bca516c43fc40dab216bd2cc6 Mon Sep 17 00:00:00 2001 From: Sven Eberth Date: Thu, 30 Jul 2026 01:10:20 +0200 Subject: [PATCH 3/3] fix: SyntaxError and dynamical name --- src/viur/shop/skeletons/_bones.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/viur/shop/skeletons/_bones.py b/src/viur/shop/skeletons/_bones.py index 35dffae..94d0325 100644 --- a/src/viur/shop/skeletons/_bones.py +++ b/src/viur/shop/skeletons/_bones.py @@ -41,12 +41,12 @@ def __init__( self, *args: t.Any, is_frozen: t.Callable[[SkeletonInstance], bool] = lambda skel: bool(skel["is_frozen"]), - updateLevel: RelationalUpdateLevel = RelationalUpdateLevel.Always + updateLevel: RelationalUpdateLevel = RelationalUpdateLevel.Always, **kwargs: t.Any, ) -> None: # The live-sync behaviour relies on updateLevel=Always if updateLevel != RelationalUpdateLevel.Always: - raise ValueError("SnapshotRelationalBone only accepts RelationalUpdateLevel.Always") + raise ValueError(f"{type(self).__name__} only accepts RelationalUpdateLevel.Always") super().__init__(updateLevel=updateLevel, *args, **kwargs) self.is_frozen = is_frozen