diff --git a/src/viur/shop/skeletons/_bones.py b/src/viur/shop/skeletons/_bones.py new file mode 100644 index 0000000..94d0325 --- /dev/null +++ b/src/viur/shop/skeletons/_bones.py @@ -0,0 +1,59 @@ +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"]), + updateLevel: RelationalUpdateLevel = RelationalUpdateLevel.Always, + **kwargs: t.Any, + ) -> None: + # The live-sync behaviour relies on updateLevel=Always + if updateLevel != RelationalUpdateLevel.Always: + raise ValueError(f"{type(self).__name__} only accepts RelationalUpdateLevel.Always") + super().__init__(updateLevel=updateLevel, *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(