woltapi 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- woltapi/__init__.py +90 -0
- woltapi/client.py +565 -0
- woltapi/credentials.py +67 -0
- woltapi/errors.py +101 -0
- woltapi/models.py +47 -0
- woltapi/purchase.py +1103 -0
- woltapi/selection.py +863 -0
- woltapi/services.py +24 -0
- woltapi/transport.py +198 -0
- woltapi-0.0.1.dist-info/METADATA +215 -0
- woltapi-0.0.1.dist-info/RECORD +14 -0
- woltapi-0.0.1.dist-info/WHEEL +5 -0
- woltapi-0.0.1.dist-info/licenses/LICENSE +661 -0
- woltapi-0.0.1.dist-info/top_level.txt +1 -0
woltapi/selection.py
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
"""Local selections, immutable quote snapshots, and explicit wire serializers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import ResponseShapeError, SelectionError, UnsupportedSelectionError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class VenueCheckoutContext:
|
|
16
|
+
"""Explicit venue fields for the observed immediate-delivery quote shape."""
|
|
17
|
+
|
|
18
|
+
id: str
|
|
19
|
+
country: str
|
|
20
|
+
currency: str
|
|
21
|
+
self_delivery: bool
|
|
22
|
+
preorder_config: Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class DeliverySelection:
|
|
27
|
+
"""A saved delivery reference with caller-supplied numeric coordinates."""
|
|
28
|
+
|
|
29
|
+
delivery_info_id: str
|
|
30
|
+
latitude: int | float = field(repr=False)
|
|
31
|
+
longitude: int | float = field(repr=False)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class OptionValueSelection:
|
|
36
|
+
"""A selected root-option value and its explicit count."""
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
count: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class OptionSelection:
|
|
44
|
+
"""A selected item option configuration and its selected values.
|
|
45
|
+
|
|
46
|
+
``post_checkout_fields`` is an exact, caller-supplied mapping for the
|
|
47
|
+
distinct post-checkout wire format. In particular, its ``type`` is not
|
|
48
|
+
inferred from the assortment root option type.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
configuration_id: str
|
|
52
|
+
values: Sequence[OptionValueSelection]
|
|
53
|
+
post_checkout_fields: Mapping[str, Any] | None = field(default=None, repr=False)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ItemSelection:
|
|
58
|
+
"""Explicit local selection data for one catalog item.
|
|
59
|
+
|
|
60
|
+
Prices that cannot be derived safely are caller inputs: ``basket_price``
|
|
61
|
+
and ``end_amount`` remain independent. ``checkout_fields`` and
|
|
62
|
+
``post_checkout_fields`` carry exact field names for their respective wire
|
|
63
|
+
representations and are copied without filling omitted keys.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
id: str
|
|
67
|
+
count: int
|
|
68
|
+
basket_name: str
|
|
69
|
+
basket_price: int
|
|
70
|
+
end_amount: int
|
|
71
|
+
substitution_allowed: bool
|
|
72
|
+
checkout_fields: Mapping[str, Any]
|
|
73
|
+
options: Sequence[OptionSelection] = ()
|
|
74
|
+
post_checkout_fields: Mapping[str, Any] | None = field(default=None, repr=False)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class SavedBasket:
|
|
79
|
+
"""Reference returned after the explicit persisted-basket mutation."""
|
|
80
|
+
|
|
81
|
+
id: str
|
|
82
|
+
venue_id: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PostCheckoutConfig:
|
|
86
|
+
"""Required-consent data returned for a selected post-checkout basket."""
|
|
87
|
+
|
|
88
|
+
__slots__ = ("_required_consents", "_selection_identity")
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self, required_consents: Sequence[Any], selection_identity: object | None = None
|
|
92
|
+
) -> None:
|
|
93
|
+
self._required_consents = deepcopy(list(required_consents))
|
|
94
|
+
self._selection_identity = selection_identity
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def required_consents(self) -> list[Any]:
|
|
98
|
+
"""Return a detached copy of the server-provided consent list."""
|
|
99
|
+
|
|
100
|
+
return deepcopy(self._required_consents)
|
|
101
|
+
|
|
102
|
+
def __repr__(self) -> str:
|
|
103
|
+
return f"PostCheckoutConfig(required_consents={len(self._required_consents)})"
|
|
104
|
+
|
|
105
|
+
def is_current_for(self, selection: OrderSelection) -> bool:
|
|
106
|
+
"""Whether these consents were fetched for the same local selection."""
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
isinstance(selection, OrderSelection)
|
|
110
|
+
and self._selection_identity is selection._identity_token()
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def _from_response(
|
|
115
|
+
cls,
|
|
116
|
+
response: Mapping[str, Any],
|
|
117
|
+
service: str,
|
|
118
|
+
selection: OrderSelection,
|
|
119
|
+
) -> PostCheckoutConfig:
|
|
120
|
+
required_consents = response.get("required_consents")
|
|
121
|
+
if not _is_array(required_consents):
|
|
122
|
+
raise ResponseShapeError(service)
|
|
123
|
+
return cls(required_consents, selection._identity_token())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class QuoteSnapshot:
|
|
127
|
+
"""Private, independent snapshots of selected catalog data, plan, and quote."""
|
|
128
|
+
|
|
129
|
+
__slots__ = ("_selection_data", "_selection_identity", "_plan", "_quote")
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
selection_data: Mapping[str, Any],
|
|
134
|
+
selection_identity: object,
|
|
135
|
+
plan: Mapping[str, Any],
|
|
136
|
+
quote: Mapping[str, Any],
|
|
137
|
+
) -> None:
|
|
138
|
+
self._selection_data = deepcopy(dict(selection_data))
|
|
139
|
+
self._selection_identity = selection_identity
|
|
140
|
+
self._plan = deepcopy(dict(plan))
|
|
141
|
+
self._quote = deepcopy(dict(quote))
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def checkout_id(self) -> str:
|
|
145
|
+
return self._quote["id"]
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def payable_amount(self) -> int | float:
|
|
149
|
+
return deepcopy(self._quote["payable_amount"])
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def purchase_validation(self) -> dict[str, Any]:
|
|
153
|
+
"""Return an unchanged, detached copy of the server validation object."""
|
|
154
|
+
|
|
155
|
+
return deepcopy(self._quote["purchase_validation"])
|
|
156
|
+
|
|
157
|
+
def is_current_for(self, selection: OrderSelection) -> bool:
|
|
158
|
+
"""Whether this snapshot belongs to the unchanged local selection."""
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
isinstance(selection, OrderSelection)
|
|
162
|
+
and self._selection_identity is selection._identity_token()
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def __repr__(self) -> str:
|
|
166
|
+
return f"QuoteSnapshot(checkout_id={self.checkout_id!r})"
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def _capture(
|
|
170
|
+
cls,
|
|
171
|
+
selection: OrderSelection,
|
|
172
|
+
plan: Mapping[str, Any],
|
|
173
|
+
quote: Mapping[str, Any],
|
|
174
|
+
service: str,
|
|
175
|
+
) -> QuoteSnapshot:
|
|
176
|
+
checkout_id = quote.get("id")
|
|
177
|
+
payable_amount = quote.get("payable_amount")
|
|
178
|
+
validation = quote.get("purchase_validation")
|
|
179
|
+
if (
|
|
180
|
+
not _is_nonempty_text(checkout_id)
|
|
181
|
+
or not _is_number(payable_amount)
|
|
182
|
+
or not isinstance(validation, Mapping)
|
|
183
|
+
):
|
|
184
|
+
raise ResponseShapeError(service)
|
|
185
|
+
return cls(
|
|
186
|
+
selection._snapshot_data(),
|
|
187
|
+
selection._identity_token(),
|
|
188
|
+
plan,
|
|
189
|
+
quote,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _plan_snapshot(self) -> dict[str, Any]:
|
|
193
|
+
"""Private handoff for a later, explicitly approved purchase phase."""
|
|
194
|
+
|
|
195
|
+
return deepcopy(self._plan)
|
|
196
|
+
|
|
197
|
+
def _quote_snapshot(self) -> dict[str, Any]:
|
|
198
|
+
"""Private handoff for a later, explicitly approved purchase phase."""
|
|
199
|
+
|
|
200
|
+
return deepcopy(self._quote)
|
|
201
|
+
|
|
202
|
+
def _selection_snapshot(self) -> dict[str, Any]:
|
|
203
|
+
"""Private handoff for a later, explicitly approved purchase phase."""
|
|
204
|
+
|
|
205
|
+
return deepcopy(self._selection_data)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class OrderSelection:
|
|
209
|
+
"""An immutable local selection with separate basket, quote, and consent serializers."""
|
|
210
|
+
|
|
211
|
+
__slots__ = ("_data", "_identity")
|
|
212
|
+
|
|
213
|
+
def __init__(self, data: Mapping[str, Any]) -> None:
|
|
214
|
+
self._data = deepcopy(dict(data))
|
|
215
|
+
self._identity = object()
|
|
216
|
+
|
|
217
|
+
def __repr__(self) -> str:
|
|
218
|
+
return f"OrderSelection(item_count={len(self._data['items'])})"
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def _from_assortment(
|
|
222
|
+
cls,
|
|
223
|
+
assortment: Mapping[str, Any],
|
|
224
|
+
*,
|
|
225
|
+
venue: VenueCheckoutContext,
|
|
226
|
+
delivery: DeliverySelection,
|
|
227
|
+
payment_method: Mapping[str, Any],
|
|
228
|
+
courier_tip: int,
|
|
229
|
+
items: Sequence[ItemSelection],
|
|
230
|
+
) -> OrderSelection:
|
|
231
|
+
if not isinstance(assortment, Mapping):
|
|
232
|
+
raise SelectionError("A selection requires an assortment mapping.")
|
|
233
|
+
if not isinstance(venue, VenueCheckoutContext):
|
|
234
|
+
raise TypeError("venue must be a VenueCheckoutContext instance.")
|
|
235
|
+
if not isinstance(delivery, DeliverySelection):
|
|
236
|
+
raise TypeError("delivery must be a DeliverySelection instance.")
|
|
237
|
+
if not _is_array(items) or not items:
|
|
238
|
+
raise SelectionError("A selection requires at least one item.")
|
|
239
|
+
|
|
240
|
+
catalog_items = _index_by_id(assortment.get("items"))
|
|
241
|
+
root_options = _index_by_id(assortment.get("options"))
|
|
242
|
+
normalized_items = [
|
|
243
|
+
_normalize_item(item, catalog_items, root_options) for item in items
|
|
244
|
+
]
|
|
245
|
+
item_ids = [item["id"] for item in normalized_items]
|
|
246
|
+
if len(item_ids) != len(set(item_ids)):
|
|
247
|
+
raise SelectionError("A selection cannot contain duplicate item IDs.")
|
|
248
|
+
|
|
249
|
+
return cls(
|
|
250
|
+
{
|
|
251
|
+
"venue": _normalize_venue(venue),
|
|
252
|
+
"delivery": _normalize_delivery(delivery),
|
|
253
|
+
"payment_method": _normalize_payment_method(payment_method),
|
|
254
|
+
"courier_tip": _nonnegative_integer(courier_tip),
|
|
255
|
+
"items": normalized_items,
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def to_basket_payload(self) -> dict[str, Any]:
|
|
260
|
+
"""Serialize the selected items for ``POST /order-xp/v1/baskets``."""
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
"venue_id": self._data["venue"]["id"],
|
|
264
|
+
"currency": self._data["venue"]["currency"],
|
|
265
|
+
"items": [_serialize_basket_item(item) for item in self._data["items"]],
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
def to_checkout_payload(self) -> dict[str, Any]:
|
|
269
|
+
"""Serialize the selected items for the checkout-quote endpoint."""
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
"purchase_plan": {
|
|
273
|
+
"courier_tip": self._data["courier_tip"],
|
|
274
|
+
"delivery": {
|
|
275
|
+
"delivery_coordinates": {
|
|
276
|
+
"longitude": self._data["delivery"]["longitude"],
|
|
277
|
+
"latitude": self._data["delivery"]["latitude"],
|
|
278
|
+
},
|
|
279
|
+
"delivery_info_id": self._data["delivery"]["delivery_info_id"],
|
|
280
|
+
},
|
|
281
|
+
"delivery_method": "homedelivery",
|
|
282
|
+
"delivery_config": {
|
|
283
|
+
"method": "homedelivery",
|
|
284
|
+
"schedule": "standard",
|
|
285
|
+
"time_slot": None,
|
|
286
|
+
},
|
|
287
|
+
"payment_methods": [deepcopy(self._data["payment_method"])],
|
|
288
|
+
"menu_items": [
|
|
289
|
+
_serialize_checkout_item(item) for item in self._data["items"]
|
|
290
|
+
],
|
|
291
|
+
"selected_offer_ids": [],
|
|
292
|
+
"use_cash": False,
|
|
293
|
+
"use_credits_and_tokens": False,
|
|
294
|
+
"use_loyalty_points_amount": 0,
|
|
295
|
+
"use_promo_surcharge_ids": [],
|
|
296
|
+
"venue": deepcopy(self._data["venue"]),
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
def to_post_checkout_payload(self) -> dict[str, Any]:
|
|
301
|
+
"""Serialize the distinct post-checkout basket representation."""
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
"venue_id": self._data["venue"]["id"],
|
|
305
|
+
"country": self._data["venue"]["country"],
|
|
306
|
+
"basket": [
|
|
307
|
+
_serialize_post_checkout_item(item) for item in self._data["items"]
|
|
308
|
+
],
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
def _payment_eligibility_context(self) -> dict[str, Any]:
|
|
312
|
+
"""Return the minimal selected-item context used for card eligibility."""
|
|
313
|
+
|
|
314
|
+
items: list[dict[str, Any]] = []
|
|
315
|
+
for item in self._data["items"]:
|
|
316
|
+
post_checkout_item = _serialize_post_checkout_item(item)
|
|
317
|
+
items.append(
|
|
318
|
+
{
|
|
319
|
+
"id": item["id"],
|
|
320
|
+
"alcohol_permille": item["checkout_fields"]["alcohol_permille"],
|
|
321
|
+
"product_hierarchy_tags": post_checkout_item[
|
|
322
|
+
"product_hierarchy_tags"
|
|
323
|
+
],
|
|
324
|
+
"vat_percentage": post_checkout_item["vat_percentage"],
|
|
325
|
+
"vat_percentage_decimal": post_checkout_item[
|
|
326
|
+
"vat_percentage_decimal"
|
|
327
|
+
],
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
return {
|
|
331
|
+
"venue_id": self._data["venue"]["id"],
|
|
332
|
+
"delivery_method": "homedelivery",
|
|
333
|
+
"items": items,
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
def _snapshot_data(self) -> dict[str, Any]:
|
|
337
|
+
return deepcopy(self._data)
|
|
338
|
+
|
|
339
|
+
def _identity_token(self) -> object:
|
|
340
|
+
return self._identity
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
_CHECKOUT_REQUIRED_FIELDS = {
|
|
344
|
+
"category_id",
|
|
345
|
+
"category_ids",
|
|
346
|
+
"exclude_from_credits",
|
|
347
|
+
"exclude_from_discounts",
|
|
348
|
+
"exclude_from_discounts_min_basket",
|
|
349
|
+
"alcohol_permille",
|
|
350
|
+
"restrictions",
|
|
351
|
+
}
|
|
352
|
+
_CHECKOUT_RESERVED_FIELDS = {"id", "count", "options", "base_price", "end_amount"}
|
|
353
|
+
_POST_CHECKOUT_REQUIRED_FIELDS = {
|
|
354
|
+
"configIndex",
|
|
355
|
+
"name",
|
|
356
|
+
"category",
|
|
357
|
+
"exclude_from_credits",
|
|
358
|
+
"exclude_from_discounts",
|
|
359
|
+
"exclude_from_discounts_min_basket",
|
|
360
|
+
"from_recommendation",
|
|
361
|
+
"alcohol_percentage",
|
|
362
|
+
"product_hierarchy_tags",
|
|
363
|
+
"vat_percentage",
|
|
364
|
+
"vat_percentage_decimal",
|
|
365
|
+
}
|
|
366
|
+
_POST_CHECKOUT_RESERVED_FIELDS = {
|
|
367
|
+
"id",
|
|
368
|
+
"count",
|
|
369
|
+
"baseprice",
|
|
370
|
+
"end_amount",
|
|
371
|
+
"restrictions",
|
|
372
|
+
"options",
|
|
373
|
+
"checksum",
|
|
374
|
+
}
|
|
375
|
+
_POST_CHECKOUT_OPTION_RESERVED_FIELDS = {"id", "values"}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _normalize_venue(venue: VenueCheckoutContext) -> dict[str, Any]:
|
|
379
|
+
if (
|
|
380
|
+
not _is_nonempty_text(venue.id)
|
|
381
|
+
or not _is_nonempty_text(venue.country)
|
|
382
|
+
or not _is_nonempty_text(venue.currency)
|
|
383
|
+
or not isinstance(venue.self_delivery, bool)
|
|
384
|
+
):
|
|
385
|
+
raise SelectionError("The checkout venue context is incomplete.")
|
|
386
|
+
if venue.preorder_config is not None:
|
|
387
|
+
raise UnsupportedSelectionError(
|
|
388
|
+
"Scheduled or preorder selections are unsupported."
|
|
389
|
+
)
|
|
390
|
+
return {
|
|
391
|
+
"id": venue.id,
|
|
392
|
+
"country": venue.country,
|
|
393
|
+
"currency": venue.currency,
|
|
394
|
+
"self_delivery": venue.self_delivery,
|
|
395
|
+
"preorder_config": None,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _normalize_delivery(delivery: DeliverySelection) -> dict[str, Any]:
|
|
400
|
+
if not _is_nonempty_text(delivery.delivery_info_id):
|
|
401
|
+
raise SelectionError("A saved delivery reference is required.")
|
|
402
|
+
return {
|
|
403
|
+
"delivery_info_id": delivery.delivery_info_id,
|
|
404
|
+
"latitude": _finite_coordinate(delivery.latitude),
|
|
405
|
+
"longitude": _finite_coordinate(delivery.longitude),
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _normalize_payment_method(payment_method: Mapping[str, Any]) -> dict[str, Any]:
|
|
410
|
+
if not isinstance(payment_method, Mapping):
|
|
411
|
+
raise TypeError("payment_method must be a mapping.")
|
|
412
|
+
method = deepcopy(dict(payment_method))
|
|
413
|
+
if not _is_nonempty_text(method.get("id")) or method.get("type") != "card":
|
|
414
|
+
raise SelectionError("A saved card payment method is required.")
|
|
415
|
+
return method
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _normalize_item(
|
|
419
|
+
selected_item: ItemSelection,
|
|
420
|
+
catalog_items: Mapping[str, Mapping[str, Any]],
|
|
421
|
+
root_options: Mapping[str, Mapping[str, Any]],
|
|
422
|
+
) -> dict[str, Any]:
|
|
423
|
+
if not isinstance(selected_item, ItemSelection):
|
|
424
|
+
raise TypeError("items must contain ItemSelection instances.")
|
|
425
|
+
if not _is_nonempty_text(selected_item.id):
|
|
426
|
+
raise SelectionError("An item ID is required.")
|
|
427
|
+
catalog_item = catalog_items.get(selected_item.id)
|
|
428
|
+
if catalog_item is None:
|
|
429
|
+
raise SelectionError("The selected item is absent from the assortment.")
|
|
430
|
+
|
|
431
|
+
count = _positive_integer(selected_item.count)
|
|
432
|
+
_validate_catalog_item_for_home_delivery(catalog_item)
|
|
433
|
+
base_price = _integer(catalog_item.get("price"))
|
|
434
|
+
checksum = catalog_item.get("checksum")
|
|
435
|
+
if not _is_nonempty_text(checksum):
|
|
436
|
+
raise SelectionError("The selected catalog item is missing a checksum.")
|
|
437
|
+
_validate_item_quantity(catalog_item, count)
|
|
438
|
+
|
|
439
|
+
if not _is_nonempty_text(selected_item.basket_name):
|
|
440
|
+
raise SelectionError("A basket item name is required.")
|
|
441
|
+
if not isinstance(selected_item.substitution_allowed, bool):
|
|
442
|
+
raise SelectionError("The substitution setting must be explicit.")
|
|
443
|
+
|
|
444
|
+
options = _normalize_options(selected_item.options, catalog_item, root_options)
|
|
445
|
+
checkout_fields = _normalize_checkout_fields(
|
|
446
|
+
selected_item.checkout_fields,
|
|
447
|
+
catalog_item["restrictions"],
|
|
448
|
+
)
|
|
449
|
+
post_checkout_fields = _copy_optional_mapping(selected_item.post_checkout_fields)
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
"id": selected_item.id,
|
|
453
|
+
"count": count,
|
|
454
|
+
"basket_name": selected_item.basket_name,
|
|
455
|
+
"basket_price": _integer(selected_item.basket_price),
|
|
456
|
+
"end_amount": _integer(selected_item.end_amount),
|
|
457
|
+
"substitution_allowed": selected_item.substitution_allowed,
|
|
458
|
+
"base_price": base_price,
|
|
459
|
+
"checksum": checksum,
|
|
460
|
+
"checkout_fields": checkout_fields,
|
|
461
|
+
"post_checkout_fields": post_checkout_fields,
|
|
462
|
+
"options": options,
|
|
463
|
+
"catalog": {
|
|
464
|
+
"item": deepcopy(dict(catalog_item)),
|
|
465
|
+
"options": [deepcopy(option["catalog"]) for option in options],
|
|
466
|
+
},
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _validate_catalog_item_for_home_delivery(catalog_item: Mapping[str, Any]) -> None:
|
|
471
|
+
delivery_methods = catalog_item.get("allowed_delivery_methods")
|
|
472
|
+
restrictions = catalog_item.get("restrictions")
|
|
473
|
+
if not _is_array(delivery_methods) or "homedelivery" not in delivery_methods:
|
|
474
|
+
raise UnsupportedSelectionError(
|
|
475
|
+
"The selected item does not support home delivery."
|
|
476
|
+
)
|
|
477
|
+
if not _is_array(restrictions) or restrictions:
|
|
478
|
+
raise UnsupportedSelectionError("Items with restrictions are unsupported.")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _validate_item_quantity(catalog_item: Mapping[str, Any], count: int) -> None:
|
|
482
|
+
minimum = catalog_item.get("min_quantity_per_purchase")
|
|
483
|
+
maximum = catalog_item.get("max_quantity_per_purchase")
|
|
484
|
+
if minimum is not None and (not _is_integer(minimum) or count < minimum):
|
|
485
|
+
raise SelectionError("The item count is outside the catalog minimum.")
|
|
486
|
+
if maximum is not None and (not _is_integer(maximum) or count > maximum):
|
|
487
|
+
raise SelectionError("The item count is outside the catalog maximum.")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _normalize_checkout_fields(
|
|
491
|
+
fields: Mapping[str, Any], catalog_restrictions: Sequence[Any]
|
|
492
|
+
) -> dict[str, Any]:
|
|
493
|
+
fields_copy = _copy_mapping(fields, "checkout_fields")
|
|
494
|
+
_validate_wire_fields(
|
|
495
|
+
fields_copy,
|
|
496
|
+
required=_CHECKOUT_REQUIRED_FIELDS,
|
|
497
|
+
reserved=_CHECKOUT_RESERVED_FIELDS,
|
|
498
|
+
label="checkout_fields",
|
|
499
|
+
)
|
|
500
|
+
if (
|
|
501
|
+
not _is_nonempty_text(fields_copy["category_id"])
|
|
502
|
+
or not _is_array(fields_copy["category_ids"])
|
|
503
|
+
or not all(
|
|
504
|
+
_is_nonempty_text(category) for category in fields_copy["category_ids"]
|
|
505
|
+
)
|
|
506
|
+
or any(
|
|
507
|
+
not isinstance(fields_copy[field], bool)
|
|
508
|
+
for field in (
|
|
509
|
+
"exclude_from_credits",
|
|
510
|
+
"exclude_from_discounts",
|
|
511
|
+
"exclude_from_discounts_min_basket",
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
):
|
|
515
|
+
raise SelectionError("The checkout item fields are incomplete.")
|
|
516
|
+
if not _is_zero_number(fields_copy["alcohol_permille"]):
|
|
517
|
+
raise UnsupportedSelectionError("Age-restricted items are unsupported.")
|
|
518
|
+
if (
|
|
519
|
+
not _is_array(fields_copy["restrictions"])
|
|
520
|
+
or fields_copy["restrictions"]
|
|
521
|
+
or fields_copy["restrictions"] != catalog_restrictions
|
|
522
|
+
):
|
|
523
|
+
raise UnsupportedSelectionError("Items with restrictions are unsupported.")
|
|
524
|
+
return fields_copy
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _normalize_options(
|
|
528
|
+
selected_options: Sequence[OptionSelection],
|
|
529
|
+
catalog_item: Mapping[str, Any],
|
|
530
|
+
root_options: Mapping[str, Mapping[str, Any]],
|
|
531
|
+
) -> list[dict[str, Any]]:
|
|
532
|
+
if not _is_array(selected_options):
|
|
533
|
+
raise SelectionError("Item options must be a sequence.")
|
|
534
|
+
configurations = _index_by_id(catalog_item.get("options"))
|
|
535
|
+
normalized: list[dict[str, Any]] = []
|
|
536
|
+
configuration_ids: set[str] = set()
|
|
537
|
+
for selected_option in selected_options:
|
|
538
|
+
if not isinstance(selected_option, OptionSelection):
|
|
539
|
+
raise TypeError("options must contain OptionSelection instances.")
|
|
540
|
+
config_id = selected_option.configuration_id
|
|
541
|
+
if not _is_nonempty_text(config_id) or config_id in configuration_ids:
|
|
542
|
+
raise SelectionError("Option configuration IDs must be unique.")
|
|
543
|
+
configuration_ids.add(config_id)
|
|
544
|
+
configuration = configurations.get(config_id)
|
|
545
|
+
if configuration is None:
|
|
546
|
+
raise SelectionError("The selected option is not an item configuration.")
|
|
547
|
+
root_option = _root_option_for_configuration(configuration, root_options)
|
|
548
|
+
constraints = _option_constraints(configuration, root_option)
|
|
549
|
+
root_values = _index_by_id(root_option.get("values"))
|
|
550
|
+
if not _is_array(selected_option.values) or not selected_option.values:
|
|
551
|
+
raise SelectionError("Each selected option requires at least one value.")
|
|
552
|
+
|
|
553
|
+
values: list[dict[str, Any]] = []
|
|
554
|
+
value_ids: set[str] = set()
|
|
555
|
+
selected_value_catalog: list[dict[str, Any]] = []
|
|
556
|
+
for selected_value in selected_option.values:
|
|
557
|
+
if not isinstance(selected_value, OptionValueSelection):
|
|
558
|
+
raise TypeError(
|
|
559
|
+
"option values must contain OptionValueSelection instances."
|
|
560
|
+
)
|
|
561
|
+
value_id = selected_value.id
|
|
562
|
+
if not _is_nonempty_text(value_id) or value_id in value_ids:
|
|
563
|
+
raise SelectionError("Option value IDs must be unique.")
|
|
564
|
+
value_ids.add(value_id)
|
|
565
|
+
catalog_value = root_values.get(value_id)
|
|
566
|
+
if catalog_value is None:
|
|
567
|
+
raise SelectionError(
|
|
568
|
+
"The selected option value is absent from the root option."
|
|
569
|
+
)
|
|
570
|
+
values.append(
|
|
571
|
+
{
|
|
572
|
+
"id": value_id,
|
|
573
|
+
"count": _positive_integer(selected_value.count),
|
|
574
|
+
"price": _integer(catalog_value.get("price")),
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
selected_value_catalog.append(deepcopy(dict(catalog_value)))
|
|
578
|
+
|
|
579
|
+
selected_total = sum(value["count"] for value in values)
|
|
580
|
+
if (
|
|
581
|
+
selected_total < constraints.total_minimum
|
|
582
|
+
or selected_total > constraints.total_maximum
|
|
583
|
+
or any(
|
|
584
|
+
value["count"] > constraints.maximum_single_selections
|
|
585
|
+
for value in values
|
|
586
|
+
)
|
|
587
|
+
):
|
|
588
|
+
raise SelectionError("Selected option counts violate catalog constraints.")
|
|
589
|
+
|
|
590
|
+
normalized.append(
|
|
591
|
+
{
|
|
592
|
+
"configuration_id": config_id,
|
|
593
|
+
"values": values,
|
|
594
|
+
"post_checkout_fields": _copy_optional_mapping(
|
|
595
|
+
selected_option.post_checkout_fields
|
|
596
|
+
),
|
|
597
|
+
"catalog": {
|
|
598
|
+
"configuration": deepcopy(dict(configuration)),
|
|
599
|
+
"root_option": deepcopy(dict(root_option)),
|
|
600
|
+
"values": selected_value_catalog,
|
|
601
|
+
},
|
|
602
|
+
}
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
for configuration_id, configuration in configurations.items():
|
|
606
|
+
if configuration_id in configuration_ids:
|
|
607
|
+
continue
|
|
608
|
+
root_option = _root_option_for_configuration(configuration, root_options)
|
|
609
|
+
constraints = _option_constraints(configuration, root_option)
|
|
610
|
+
if constraints.total_minimum > 0:
|
|
611
|
+
raise SelectionError("A required option configuration was omitted.")
|
|
612
|
+
return normalized
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@dataclass(frozen=True)
|
|
616
|
+
class _OptionConstraints:
|
|
617
|
+
total_minimum: int
|
|
618
|
+
total_maximum: int
|
|
619
|
+
maximum_single_selections: int
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _root_option_for_configuration(
|
|
623
|
+
configuration: Mapping[str, Any], root_options: Mapping[str, Mapping[str, Any]]
|
|
624
|
+
) -> Mapping[str, Any]:
|
|
625
|
+
prerequisites = configuration.get("prerequisite_values")
|
|
626
|
+
if not _is_array(prerequisites) or prerequisites:
|
|
627
|
+
raise UnsupportedSelectionError("Option prerequisites are unsupported.")
|
|
628
|
+
root_option_id = configuration.get("option_id")
|
|
629
|
+
if not _is_nonempty_text(root_option_id):
|
|
630
|
+
raise SelectionError("The item option is missing its root option reference.")
|
|
631
|
+
root_option = root_options.get(root_option_id)
|
|
632
|
+
if root_option is None:
|
|
633
|
+
raise SelectionError("The item option root is absent from the assortment.")
|
|
634
|
+
return root_option
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _option_constraints(
|
|
638
|
+
configuration: Mapping[str, Any], root_option: Mapping[str, Any]
|
|
639
|
+
) -> _OptionConstraints:
|
|
640
|
+
if root_option.get("type") not in {"choice", "multi_choice"}:
|
|
641
|
+
raise UnsupportedSelectionError("The root option type is unsupported.")
|
|
642
|
+
configuration_rules = configuration.get("multi_choice_config")
|
|
643
|
+
if not isinstance(configuration_rules, Mapping):
|
|
644
|
+
raise UnsupportedSelectionError("Option selection constraints are unavailable.")
|
|
645
|
+
total_range = configuration_rules.get("total_range")
|
|
646
|
+
if not isinstance(total_range, Mapping):
|
|
647
|
+
raise UnsupportedSelectionError("Option selection constraints are unavailable.")
|
|
648
|
+
minimum = total_range.get("min")
|
|
649
|
+
maximum = total_range.get("max")
|
|
650
|
+
maximum_single = configuration_rules.get("max_single_selections")
|
|
651
|
+
free_selections = configuration_rules.get("free_selections")
|
|
652
|
+
if (
|
|
653
|
+
not _is_integer(minimum)
|
|
654
|
+
or not _is_integer(maximum)
|
|
655
|
+
or not _is_integer(maximum_single)
|
|
656
|
+
or not _is_integer(free_selections)
|
|
657
|
+
or minimum < 0
|
|
658
|
+
or maximum < minimum
|
|
659
|
+
or maximum_single <= 0
|
|
660
|
+
or free_selections != 0
|
|
661
|
+
):
|
|
662
|
+
raise UnsupportedSelectionError("Option selection constraints are unsupported.")
|
|
663
|
+
return _OptionConstraints(minimum, maximum, maximum_single)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _serialize_basket_item(item: Mapping[str, Any]) -> dict[str, Any]:
|
|
667
|
+
return {
|
|
668
|
+
"id": item["id"],
|
|
669
|
+
"count": item["count"],
|
|
670
|
+
"name": item["basket_name"],
|
|
671
|
+
"price": item["basket_price"],
|
|
672
|
+
"options": [
|
|
673
|
+
{
|
|
674
|
+
"id": option["configuration_id"],
|
|
675
|
+
"values": deepcopy(option["values"]),
|
|
676
|
+
}
|
|
677
|
+
for option in item["options"]
|
|
678
|
+
],
|
|
679
|
+
"substitution_settings": {"is_allowed": item["substitution_allowed"]},
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _serialize_checkout_item(item: Mapping[str, Any]) -> dict[str, Any]:
|
|
684
|
+
payload = deepcopy(item["checkout_fields"])
|
|
685
|
+
payload.update(
|
|
686
|
+
{
|
|
687
|
+
"id": item["id"],
|
|
688
|
+
"count": item["count"],
|
|
689
|
+
"options": [
|
|
690
|
+
{
|
|
691
|
+
"id": option["configuration_id"],
|
|
692
|
+
"values": deepcopy(option["values"]),
|
|
693
|
+
}
|
|
694
|
+
for option in item["options"]
|
|
695
|
+
],
|
|
696
|
+
"base_price": item["base_price"],
|
|
697
|
+
"end_amount": item["end_amount"],
|
|
698
|
+
}
|
|
699
|
+
)
|
|
700
|
+
return payload
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _serialize_post_checkout_item(item: Mapping[str, Any]) -> dict[str, Any]:
|
|
704
|
+
fields = item["post_checkout_fields"]
|
|
705
|
+
if fields is None:
|
|
706
|
+
raise UnsupportedSelectionError(
|
|
707
|
+
"Post-checkout item fields must be supplied explicitly."
|
|
708
|
+
)
|
|
709
|
+
fields_copy = _copy_mapping(fields, "post_checkout_fields")
|
|
710
|
+
_validate_wire_fields(
|
|
711
|
+
fields_copy,
|
|
712
|
+
required=_POST_CHECKOUT_REQUIRED_FIELDS,
|
|
713
|
+
reserved=_POST_CHECKOUT_RESERVED_FIELDS,
|
|
714
|
+
label="post_checkout_fields",
|
|
715
|
+
)
|
|
716
|
+
if (
|
|
717
|
+
not _is_integer(fields_copy["configIndex"])
|
|
718
|
+
or not _is_array(fields_copy["name"])
|
|
719
|
+
or not _is_nonempty_text(fields_copy["category"])
|
|
720
|
+
or not _is_array(fields_copy["product_hierarchy_tags"])
|
|
721
|
+
or any(
|
|
722
|
+
not isinstance(fields_copy[field], bool)
|
|
723
|
+
for field in (
|
|
724
|
+
"exclude_from_credits",
|
|
725
|
+
"exclude_from_discounts",
|
|
726
|
+
"exclude_from_discounts_min_basket",
|
|
727
|
+
)
|
|
728
|
+
)
|
|
729
|
+
):
|
|
730
|
+
raise SelectionError("The post-checkout item fields are incomplete.")
|
|
731
|
+
if fields_copy["from_recommendation"] is not False:
|
|
732
|
+
raise UnsupportedSelectionError("Recommendation selections are unsupported.")
|
|
733
|
+
if not _is_zero_number(fields_copy["alcohol_percentage"]):
|
|
734
|
+
raise UnsupportedSelectionError("Age-restricted items are unsupported.")
|
|
735
|
+
|
|
736
|
+
fields_copy.update(
|
|
737
|
+
{
|
|
738
|
+
"id": item["id"],
|
|
739
|
+
"count": item["count"],
|
|
740
|
+
"baseprice": item["base_price"],
|
|
741
|
+
"end_amount": item["end_amount"],
|
|
742
|
+
"restrictions": deepcopy(item["checkout_fields"]["restrictions"]),
|
|
743
|
+
"options": [
|
|
744
|
+
_serialize_post_checkout_option(option) for option in item["options"]
|
|
745
|
+
],
|
|
746
|
+
"checksum": item["checksum"],
|
|
747
|
+
}
|
|
748
|
+
)
|
|
749
|
+
return fields_copy
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _serialize_post_checkout_option(option: Mapping[str, Any]) -> dict[str, Any]:
|
|
753
|
+
fields = option["post_checkout_fields"]
|
|
754
|
+
if fields is None:
|
|
755
|
+
raise UnsupportedSelectionError(
|
|
756
|
+
"Post-checkout option fields must be supplied explicitly."
|
|
757
|
+
)
|
|
758
|
+
fields_copy = _copy_mapping(fields, "option post_checkout_fields")
|
|
759
|
+
_validate_wire_fields(
|
|
760
|
+
fields_copy,
|
|
761
|
+
required={"type", "name"},
|
|
762
|
+
reserved=_POST_CHECKOUT_OPTION_RESERVED_FIELDS,
|
|
763
|
+
label="option post_checkout_fields",
|
|
764
|
+
)
|
|
765
|
+
if not _is_nonempty_text(fields_copy["type"]) or not _is_array(fields_copy["name"]):
|
|
766
|
+
raise SelectionError("The post-checkout option fields are incomplete.")
|
|
767
|
+
fields_copy.update(
|
|
768
|
+
{
|
|
769
|
+
"id": option["configuration_id"],
|
|
770
|
+
"values": {value["id"]: value["count"] for value in option["values"]},
|
|
771
|
+
}
|
|
772
|
+
)
|
|
773
|
+
return fields_copy
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _index_by_id(value: Any) -> dict[str, Mapping[str, Any]]:
|
|
777
|
+
if not _is_array(value):
|
|
778
|
+
raise SelectionError("The assortment has an unexpected collection shape.")
|
|
779
|
+
indexed: dict[str, Mapping[str, Any]] = {}
|
|
780
|
+
for record in value:
|
|
781
|
+
if not isinstance(record, Mapping):
|
|
782
|
+
continue
|
|
783
|
+
record_id = record.get("id")
|
|
784
|
+
if not _is_nonempty_text(record_id):
|
|
785
|
+
continue
|
|
786
|
+
if record_id in indexed:
|
|
787
|
+
raise SelectionError("The assortment has duplicate IDs.")
|
|
788
|
+
indexed[record_id] = record
|
|
789
|
+
return indexed
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _validate_wire_fields(
|
|
793
|
+
fields: Mapping[str, Any],
|
|
794
|
+
*,
|
|
795
|
+
required: set[str],
|
|
796
|
+
reserved: set[str],
|
|
797
|
+
label: str,
|
|
798
|
+
) -> None:
|
|
799
|
+
if required.difference(fields) or reserved.intersection(fields):
|
|
800
|
+
raise SelectionError(f"{label} is missing required or has reserved fields.")
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _copy_mapping(value: Any, label: str) -> dict[str, Any]:
|
|
804
|
+
if not isinstance(value, Mapping):
|
|
805
|
+
raise TypeError(f"{label} must be a mapping.")
|
|
806
|
+
return deepcopy(dict(value))
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _copy_optional_mapping(value: Any) -> dict[str, Any] | None:
|
|
810
|
+
if value is None:
|
|
811
|
+
return None
|
|
812
|
+
return _copy_mapping(value, "post_checkout_fields")
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _is_array(value: Any) -> bool:
|
|
816
|
+
return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _is_nonempty_text(value: Any) -> bool:
|
|
820
|
+
return isinstance(value, str) and bool(value.strip())
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _is_integer(value: Any) -> bool:
|
|
824
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _integer(value: Any) -> int:
|
|
828
|
+
if not _is_integer(value):
|
|
829
|
+
raise SelectionError("Amounts and counts must be integer values.")
|
|
830
|
+
return value
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _positive_integer(value: Any) -> int:
|
|
834
|
+
value = _integer(value)
|
|
835
|
+
if value <= 0:
|
|
836
|
+
raise SelectionError("Counts must be positive integers.")
|
|
837
|
+
return value
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _nonnegative_integer(value: Any) -> int:
|
|
841
|
+
value = _integer(value)
|
|
842
|
+
if value < 0:
|
|
843
|
+
raise SelectionError("Amounts must be nonnegative integers.")
|
|
844
|
+
return value
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _is_number(value: Any) -> bool:
|
|
848
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _is_zero_number(value: Any) -> bool:
|
|
852
|
+
return _is_number(value) and value == 0
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _finite_coordinate(value: Any) -> int | float:
|
|
856
|
+
if not _is_number(value):
|
|
857
|
+
raise SelectionError("Delivery coordinates must be finite numbers.")
|
|
858
|
+
try:
|
|
859
|
+
if not math.isfinite(value):
|
|
860
|
+
raise SelectionError("Delivery coordinates must be finite numbers.")
|
|
861
|
+
except OverflowError:
|
|
862
|
+
raise SelectionError("Delivery coordinates must be finite numbers.") from None
|
|
863
|
+
return value
|