python-picnic-api2 1.3.3__py3-none-any.whl → 2.0.0__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.
- python_picnic_api2/__init__.py +46 -3
- python_picnic_api2/client.py +76 -78
- python_picnic_api2/exceptions.py +32 -0
- python_picnic_api2/helper.py +0 -123
- python_picnic_api2/models/__init__.py +45 -0
- python_picnic_api2/models/article.py +262 -0
- python_picnic_api2/models/base.py +42 -0
- python_picnic_api2/models/cart.py +28 -0
- python_picnic_api2/models/category.py +30 -0
- python_picnic_api2/models/common.py +125 -0
- python_picnic_api2/models/delivery.py +50 -0
- python_picnic_api2/models/pml.py +173 -0
- python_picnic_api2/models/search.py +65 -0
- python_picnic_api2/models/user.py +58 -0
- python_picnic_api2-2.0.0.dist-info/METADATA +205 -0
- python_picnic_api2-2.0.0.dist-info/RECORD +19 -0
- {python_picnic_api2-1.3.3.dist-info → python_picnic_api2-2.0.0.dist-info}/WHEEL +1 -1
- python_picnic_api2-1.3.3.dist-info/METADATA +0 -95
- python_picnic_api2-1.3.3.dist-info/RECORD +0 -8
- {python_picnic_api2-1.3.3.dist-info → python_picnic_api2-2.0.0.dist-info}/licenses/LICENSE.md +0 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Article model, parsed from the ``product-details-page-root`` PML page."""
|
|
2
|
+
|
|
3
|
+
from .base import PicnicModel
|
|
4
|
+
from .category import Category
|
|
5
|
+
from .pml import find, find_all, on_press_target, strip_colors, text_of, walk
|
|
6
|
+
|
|
7
|
+
# The container holding the article's title/producer/quantity text nodes.
|
|
8
|
+
_MAIN_CONTAINER_ID = "product-details-page-root-main-container"
|
|
9
|
+
_CATEGORY_BUTTON_ID = "category-button"
|
|
10
|
+
# Containers common to every product-details page (present across countries and
|
|
11
|
+
# product types); each holds one section of the page.
|
|
12
|
+
_DESCRIPTION_ID = "product-page-description"
|
|
13
|
+
_HIGHLIGHTS_ID = "product-page-highlights"
|
|
14
|
+
# Bundle/multipack pages carry one "product-page-bundle-item-<id>" container per
|
|
15
|
+
# pack size; ordinary product pages carry none.
|
|
16
|
+
_BUNDLE_ITEM_PREFIX = "product-page-bundle-item-"
|
|
17
|
+
# Leading characters that mark a price-per-unit line (e.g. "€1.15/L").
|
|
18
|
+
_CURRENCY = "€$£"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _bundle_variant_ids(data, article_id: str) -> list[str]:
|
|
22
|
+
"""Return the *other* pack-size article ids on a bundle page, in order.
|
|
23
|
+
|
|
24
|
+
Bundle/multipack pages list several pack sizes, each in a
|
|
25
|
+
``product-page-bundle-item-<id>`` container. Returns the sibling ids (all but
|
|
26
|
+
the requested article); an empty list means this is not a bundle page.
|
|
27
|
+
"""
|
|
28
|
+
ids: list[str] = []
|
|
29
|
+
for node in find_all(data, id_prefix=_BUNDLE_ITEM_PREFIX):
|
|
30
|
+
variant_id = node["id"][len(_BUNDLE_ITEM_PREFIX):]
|
|
31
|
+
if variant_id and variant_id != article_id and variant_id not in ids:
|
|
32
|
+
ids.append(variant_id)
|
|
33
|
+
return ids
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _all_texts(container) -> list[str]:
|
|
37
|
+
"""Return every non-empty (color-stripped) text in ``container``, in order."""
|
|
38
|
+
texts: list[str] = []
|
|
39
|
+
for node in walk(container):
|
|
40
|
+
text = text_of(node)
|
|
41
|
+
if text and text.strip():
|
|
42
|
+
texts.append(text.strip())
|
|
43
|
+
return texts
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _raw_text(node) -> str | None:
|
|
47
|
+
"""Return a node's raw (un-stripped) markdown/text, if it has any."""
|
|
48
|
+
for key in ("markdown", "text"):
|
|
49
|
+
value = node.get(key)
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
return value
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _price_cents(value) -> int | None:
|
|
56
|
+
"""Return an integer-cents price from a node's ``price`` value, or ``None``.
|
|
57
|
+
|
|
58
|
+
DE returns prices as ``int`` (``95``) but NL returns ``float`` (``1035.0``);
|
|
59
|
+
both must parse. ``bool`` is rejected (it is an ``int`` subclass).
|
|
60
|
+
"""
|
|
61
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
62
|
+
return None
|
|
63
|
+
return round(value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _scan_prices(scope) -> tuple[int | None, int | None]:
|
|
67
|
+
"""Return ``(price, original_price)`` from the ``PRICE`` nodes in ``scope``.
|
|
68
|
+
|
|
69
|
+
``price`` is the first non-crossed price; ``original_price`` is a
|
|
70
|
+
struck-through (``isCrossed``) price, trusted only while still within the
|
|
71
|
+
product's own price block (before a second, non-crossed price appears — later
|
|
72
|
+
prices belong to recommendation tiles).
|
|
73
|
+
"""
|
|
74
|
+
price: int | None = None
|
|
75
|
+
original_price: int | None = None
|
|
76
|
+
non_crossed_seen = 0
|
|
77
|
+
for node in walk(scope):
|
|
78
|
+
if node.get("type") != "PRICE":
|
|
79
|
+
continue
|
|
80
|
+
value = _price_cents(node.get("price"))
|
|
81
|
+
if value is None:
|
|
82
|
+
continue
|
|
83
|
+
if node.get("isCrossed"):
|
|
84
|
+
if original_price is None and non_crossed_seen <= 1:
|
|
85
|
+
original_price = value
|
|
86
|
+
else:
|
|
87
|
+
non_crossed_seen += 1
|
|
88
|
+
if price is None:
|
|
89
|
+
price = value
|
|
90
|
+
return price, original_price
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _find_prices(data, article_id: str) -> tuple[int | None, int | None]:
|
|
94
|
+
"""Return ``(price, original_price)`` in integer cents for ``article_id``.
|
|
95
|
+
|
|
96
|
+
Bundle / multipack pages list several pack sizes, each in its own
|
|
97
|
+
``product-page-bundle-item-<id>`` container with its own price, and the main
|
|
98
|
+
container carries no price at all. The price for *this* article must come from
|
|
99
|
+
its matching bundle-item container — taking the first ``PRICE`` on the page
|
|
100
|
+
would return a different pack size's price. For ordinary pages there is no such
|
|
101
|
+
container and the product's price nodes are simply the first on the page.
|
|
102
|
+
Falls back to any node carrying a numeric ``price`` so a layout variant never
|
|
103
|
+
leaves the price unset.
|
|
104
|
+
"""
|
|
105
|
+
bundle_item = find(data, id=f"product-page-bundle-item-{article_id}")
|
|
106
|
+
if bundle_item is not None:
|
|
107
|
+
price, original_price = _scan_prices(bundle_item)
|
|
108
|
+
if price is not None:
|
|
109
|
+
return price, original_price
|
|
110
|
+
|
|
111
|
+
price, original_price = _scan_prices(data)
|
|
112
|
+
if price is None:
|
|
113
|
+
for node in walk(data):
|
|
114
|
+
value = _price_cents(node.get("price"))
|
|
115
|
+
if value is not None and not node.get("isCrossed"):
|
|
116
|
+
price = value
|
|
117
|
+
break
|
|
118
|
+
return price, original_price
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_main_texts(
|
|
122
|
+
container,
|
|
123
|
+
) -> tuple[str | None, str | None, str | None, str | None]:
|
|
124
|
+
"""Classify the main container's texts into (product_name, producer,
|
|
125
|
+
unit_quantity, price_per_unit) by *role*, not position.
|
|
126
|
+
|
|
127
|
+
The title is the ``HEADER1`` node; the unit quantity is a colour-coded line
|
|
128
|
+
starting with a digit (``"1L"``, ``"5 Stück"``); the price-per-unit starts
|
|
129
|
+
with a currency symbol (``"€1.15/L"``); the producer/brand is a plain text
|
|
130
|
+
line. A positional read broke on brand-less products (produce) and trailing
|
|
131
|
+
badges like ``"Tiefkühl"``.
|
|
132
|
+
"""
|
|
133
|
+
title = producer = unit_quantity = price_per_unit = None
|
|
134
|
+
fallback_title = None
|
|
135
|
+
for node in walk(container):
|
|
136
|
+
raw = _raw_text(node)
|
|
137
|
+
if raw is None:
|
|
138
|
+
continue
|
|
139
|
+
text = strip_colors(raw).strip()
|
|
140
|
+
if not text:
|
|
141
|
+
continue
|
|
142
|
+
if fallback_title is None:
|
|
143
|
+
fallback_title = text
|
|
144
|
+
if node.get("textType") == "HEADER1" and title is None:
|
|
145
|
+
title = text
|
|
146
|
+
continue
|
|
147
|
+
colour_coded = "#(" in raw
|
|
148
|
+
first = text[0]
|
|
149
|
+
if first in _CURRENCY:
|
|
150
|
+
if price_per_unit is None:
|
|
151
|
+
price_per_unit = text
|
|
152
|
+
elif colour_coded and first.isdigit():
|
|
153
|
+
if unit_quantity is None:
|
|
154
|
+
unit_quantity = text
|
|
155
|
+
elif not colour_coded and producer is None:
|
|
156
|
+
producer = text
|
|
157
|
+
return (title or fallback_title), producer, unit_quantity, price_per_unit
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Article(PicnicModel):
|
|
161
|
+
"""A Picnic article (product)."""
|
|
162
|
+
|
|
163
|
+
id: str
|
|
164
|
+
#: The composed display name, ``"<producer> <product_name>"``.
|
|
165
|
+
name: str | None = None
|
|
166
|
+
#: The product title on its own, e.g. ``"H-Milch 3,5%"``.
|
|
167
|
+
product_name: str | None = None
|
|
168
|
+
#: The brand / producer, e.g. ``"Gut&Günstig"``. ``None`` for unbranded
|
|
169
|
+
#: products such as fresh produce.
|
|
170
|
+
producer: str | None = None
|
|
171
|
+
#: The pack size, e.g. ``"1L"`` / ``"5 Stück"``.
|
|
172
|
+
unit_quantity: str | None = None
|
|
173
|
+
#: The comparative price, e.g. ``"€1.15/L"``.
|
|
174
|
+
price_per_unit: str | None = None
|
|
175
|
+
#: The current price in integer cents, e.g. ``95``. NB: for bundles/multipacks
|
|
176
|
+
#: (:attr:`is_bundle`) Picnic exposes only a *per-unit* price here — the full
|
|
177
|
+
#: amount charged for the pack is not in the page payload and only appears once
|
|
178
|
+
#: the item is added to the cart.
|
|
179
|
+
price: int | None = None
|
|
180
|
+
#: The pre-sale price in integer cents when on sale, else ``None``.
|
|
181
|
+
original_price: int | None = None
|
|
182
|
+
#: Other pack-size article ids when this is a bundle/multipack, else ``[]``.
|
|
183
|
+
bundle_variant_ids: list[str] = []
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def is_bundle(self) -> bool:
|
|
187
|
+
"""Whether this product is a bundle/multipack with other pack sizes."""
|
|
188
|
+
return bool(self.bundle_variant_ids)
|
|
189
|
+
#: The hero product image id (usable with Picnic's image CDN).
|
|
190
|
+
image_id: str | None = None
|
|
191
|
+
#: The product description (markdown, may include ``**bold**`` and newlines).
|
|
192
|
+
description: str | None = None
|
|
193
|
+
#: Short feature bullets shown on the page, e.g. ``["Lange **haltbar**", ...]``.
|
|
194
|
+
highlights: list[str] = []
|
|
195
|
+
category: Category | None = None
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def from_page(cls, data: dict, article_id: str) -> "Article | None":
|
|
199
|
+
"""Build an :class:`Article` from a ``product-details-page-root`` payload.
|
|
200
|
+
|
|
201
|
+
The page is a layout tree. The article's title, brand, unit quantity and
|
|
202
|
+
price-per-unit are read from the main container by *role* (see
|
|
203
|
+
:func:`_parse_main_texts`), the ``price`` from the first price node (see
|
|
204
|
+
:func:`_find_price`) and the hero ``image_id`` from the page's first
|
|
205
|
+
image-gallery node. Returns ``None`` when the page has no recognizable
|
|
206
|
+
article container (e.g. an unsupported / experimental layout variant).
|
|
207
|
+
"""
|
|
208
|
+
container = find(data, id=_MAIN_CONTAINER_ID)
|
|
209
|
+
if container is None:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
product_name, producer, unit_quantity, price_per_unit = \
|
|
213
|
+
_parse_main_texts(container)
|
|
214
|
+
if not product_name:
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
price, original_price = _find_prices(data, article_id)
|
|
218
|
+
bundle_variant_ids = _bundle_variant_ids(data, article_id)
|
|
219
|
+
|
|
220
|
+
# The first image-gallery node on the page is the hero product image.
|
|
221
|
+
gallery = find(data, type="image_gallery")
|
|
222
|
+
image_id = gallery.get("image_id") if gallery else None
|
|
223
|
+
|
|
224
|
+
# Description is a single markdown block; highlights are short bullets.
|
|
225
|
+
description_container = find(data, id=_DESCRIPTION_ID)
|
|
226
|
+
description_texts = _all_texts(description_container) \
|
|
227
|
+
if description_container else []
|
|
228
|
+
description = description_texts[0] if description_texts else None
|
|
229
|
+
|
|
230
|
+
highlights_container = find(data, id=_HIGHLIGHTS_ID)
|
|
231
|
+
highlights = _all_texts(highlights_container) if highlights_container else []
|
|
232
|
+
|
|
233
|
+
# Historical behaviour composes "<producer> <product_name>" as the name.
|
|
234
|
+
display_name = " ".join(part for part in (producer, product_name) if part)
|
|
235
|
+
|
|
236
|
+
return cls(
|
|
237
|
+
id=article_id,
|
|
238
|
+
name=display_name or None,
|
|
239
|
+
product_name=product_name,
|
|
240
|
+
producer=producer,
|
|
241
|
+
unit_quantity=unit_quantity,
|
|
242
|
+
price_per_unit=price_per_unit,
|
|
243
|
+
price=price,
|
|
244
|
+
original_price=original_price,
|
|
245
|
+
bundle_variant_ids=bundle_variant_ids,
|
|
246
|
+
image_id=image_id,
|
|
247
|
+
description=description,
|
|
248
|
+
highlights=highlights,
|
|
249
|
+
raw=data,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def category_ids_from_page(data: dict) -> tuple[int, int, int] | None:
|
|
254
|
+
"""Extract ``(l1_id, l2_id, l3_id)`` from the page's category button."""
|
|
255
|
+
button = find(data, id=_CATEGORY_BUTTON_ID)
|
|
256
|
+
if button is None:
|
|
257
|
+
return None
|
|
258
|
+
target = on_press_target(button)
|
|
259
|
+
return Category.parse_deeplink(target) if target else None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
__all__ = ["Article"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared base class for all Picnic data models."""
|
|
2
|
+
|
|
3
|
+
from typing import TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, SkipValidation
|
|
6
|
+
|
|
7
|
+
_T = TypeVar("_T", bound="PicnicModel")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PicnicModel(BaseModel):
|
|
11
|
+
"""Base for every Picnic model.
|
|
12
|
+
|
|
13
|
+
Design goals, given that the Picnic API varies by country and A/B test:
|
|
14
|
+
|
|
15
|
+
- ``extra="ignore"`` so unknown/new fields never break parsing.
|
|
16
|
+
- ``populate_by_name=True`` so fields can carry a ``Field(alias=...)`` for
|
|
17
|
+
Picnic's raw keys while staying accessible by their pythonic name.
|
|
18
|
+
- Every top-level model keeps the original payload in :attr:`raw` (excluded
|
|
19
|
+
from ``model_dump()``) so callers can always reach data we haven't modelled
|
|
20
|
+
yet. It is stored verbatim (no copy/validation) so it stays the exact
|
|
21
|
+
object returned by the API, and is excluded from ``model_dump()``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
25
|
+
|
|
26
|
+
raw: SkipValidation[dict | None] = Field(default=None, exclude=True, repr=False)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_api(cls: type[_T], data: dict) -> _T:
|
|
30
|
+
"""Validate a raw domain-JSON payload and keep it verbatim on :attr:`raw`.
|
|
31
|
+
|
|
32
|
+
The shared constructor for the "clean JSON" endpoints (user, cart,
|
|
33
|
+
deliveries, …), analogous to the ``from_page`` builders the PML models
|
|
34
|
+
use. ``data`` is attached untouched so callers keep an escape hatch to
|
|
35
|
+
anything not modelled yet.
|
|
36
|
+
"""
|
|
37
|
+
obj = cls.model_validate(data)
|
|
38
|
+
obj.raw = data
|
|
39
|
+
return obj
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["PicnicModel"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Cart model, parsed from the ``/cart`` domain-JSON endpoint.
|
|
2
|
+
|
|
3
|
+
The cart is itself an ``ORDER``: its :attr:`items` are order lines. It also
|
|
4
|
+
carries the currently offered delivery slots and the selected slot.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .base import PicnicModel
|
|
8
|
+
from .common import OrderLine, SelectedSlot, Slot
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Cart(PicnicModel):
|
|
12
|
+
"""The shopping cart returned by ``get_cart`` and the cart-mutation methods."""
|
|
13
|
+
|
|
14
|
+
type: str | None = None
|
|
15
|
+
id: str | None = None
|
|
16
|
+
items: list[OrderLine] = []
|
|
17
|
+
delivery_slots: list[Slot] = []
|
|
18
|
+
selected_slot: SelectedSlot | None = None
|
|
19
|
+
total_count: int | None = None
|
|
20
|
+
total_price: int | None = None
|
|
21
|
+
checkout_total_price: int | None = None
|
|
22
|
+
mts: int | None = None
|
|
23
|
+
deposit_breakdown: list = []
|
|
24
|
+
state_token: str | None = None
|
|
25
|
+
membership_savings: int | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = ["Cart"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Category model, parsed from the L2 category page and article deep-links."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from .base import PicnicModel
|
|
6
|
+
|
|
7
|
+
# Deep-link on a product's category button, e.g.
|
|
8
|
+
# ``app.picnic://categories/1000/l2/2000/l3/3000``.
|
|
9
|
+
_CATEGORY_DEEPLINK = re.compile(
|
|
10
|
+
r"app\.picnic://categories/(\d+)/l2/(\d+)/l3/(\d+)"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Category(PicnicModel):
|
|
15
|
+
"""A Picnic category identified by its L2/L3 ids."""
|
|
16
|
+
|
|
17
|
+
l2_id: int | None = None
|
|
18
|
+
l3_id: int | None = None
|
|
19
|
+
name: str | None = None
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def parse_deeplink(cls, target: str) -> tuple[int, int, int] | None:
|
|
23
|
+
"""Extract ``(l1_id, l2_id, l3_id)`` from a category deep-link target."""
|
|
24
|
+
match = _CATEGORY_DEEPLINK.match(target or "")
|
|
25
|
+
if not match:
|
|
26
|
+
return None
|
|
27
|
+
return tuple(int(group) for group in match.groups()) # type: ignore[return-value]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = ["Category"]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Building blocks shared by the cart and delivery domain-JSON models.
|
|
2
|
+
|
|
3
|
+
These endpoints (unlike the PML ``/pages/*`` ones) return clean, nested domain
|
|
4
|
+
JSON, so the models are plain pydantic with no tree traversal. Prices are integer
|
|
5
|
+
cents, timestamps are kept as the raw ISO-8601 strings (e.g.
|
|
6
|
+
``"2026-07-15T14:15:00.000+02:00"``) to stay byte-faithful to the payload.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .base import PicnicModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Decorator(PicnicModel):
|
|
13
|
+
"""A UI/pricing decorator attached to an order article (e.g. ``QUANTITY``).
|
|
14
|
+
|
|
15
|
+
``type`` is the only key present on every variant; the extras below cover the
|
|
16
|
+
common kinds (``QUANTITY`` -> ``quantity``, ``UNIT_QUANTITY`` ->
|
|
17
|
+
``unit_quantity_text``, ``PRODUCT_SIZE`` -> ``text``). Anything else is kept
|
|
18
|
+
via ``extra="ignore"`` + the parent model's :attr:`raw`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
type: str | None = None
|
|
22
|
+
quantity: int | None = None
|
|
23
|
+
unit_quantity_text: str | None = None
|
|
24
|
+
text: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Slot(PicnicModel):
|
|
28
|
+
"""A delivery slot, as embedded in the cart, delivery-slot list and deliveries."""
|
|
29
|
+
|
|
30
|
+
slot_id: str | None = None
|
|
31
|
+
hub_id: str | None = None
|
|
32
|
+
fc_id: str | None = None
|
|
33
|
+
window_start: str | None = None
|
|
34
|
+
window_end: str | None = None
|
|
35
|
+
cut_off_time: str | None = None
|
|
36
|
+
is_available: bool | None = None
|
|
37
|
+
selected: bool | None = None
|
|
38
|
+
reserved: bool | None = None
|
|
39
|
+
minimum_order_value: int | None = None
|
|
40
|
+
unavailability_reason: str | None = None
|
|
41
|
+
slot_characteristics: list = []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SelectedSlot(PicnicModel):
|
|
45
|
+
"""The currently selected slot reference (``slot_id`` + ``state``)."""
|
|
46
|
+
|
|
47
|
+
slot_id: str | None = None
|
|
48
|
+
state: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Eta(PicnicModel):
|
|
52
|
+
"""An estimated-arrival window (``start``/``end`` ISO timestamps)."""
|
|
53
|
+
|
|
54
|
+
start: str | None = None
|
|
55
|
+
end: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TransactionInfo(PicnicModel):
|
|
59
|
+
"""Payment info attached to an order."""
|
|
60
|
+
|
|
61
|
+
bank_id: str | None = None
|
|
62
|
+
payment_type: str | None = None
|
|
63
|
+
redacted_iban: str | None = None
|
|
64
|
+
refund_account: bool | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class OrderArticle(PicnicModel):
|
|
68
|
+
"""A single article (product) inside an order line."""
|
|
69
|
+
|
|
70
|
+
type: str | None = None
|
|
71
|
+
id: str | None = None
|
|
72
|
+
name: str | None = None
|
|
73
|
+
image_ids: list[str] = []
|
|
74
|
+
unit_quantity: str | None = None
|
|
75
|
+
price: int | None = None
|
|
76
|
+
max_count: int | None = None
|
|
77
|
+
perishable: bool | None = None
|
|
78
|
+
decorators: list[Decorator] = []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OrderLine(PicnicModel):
|
|
82
|
+
"""An order line: one or more identical articles with a line price."""
|
|
83
|
+
|
|
84
|
+
type: str | None = None
|
|
85
|
+
id: str | None = None
|
|
86
|
+
items: list[OrderArticle] = []
|
|
87
|
+
display_price: int | None = None
|
|
88
|
+
price: int | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Order(PicnicModel):
|
|
92
|
+
"""An order.
|
|
93
|
+
|
|
94
|
+
A single model covers both the rich delivery-detail order (with line items and
|
|
95
|
+
payment info) and the lean order summary returned by ``/deliveries/summary``
|
|
96
|
+
(just totals + status); every field is optional so the missing ones simply
|
|
97
|
+
stay ``None``.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
type: str | None = None
|
|
101
|
+
id: str | None = None
|
|
102
|
+
items: list[OrderLine] = []
|
|
103
|
+
total_price: int | None = None
|
|
104
|
+
checkout_total_price: int | None = None
|
|
105
|
+
total_savings: int | None = None
|
|
106
|
+
total_deposit: int | None = None
|
|
107
|
+
cancellable: bool | None = None
|
|
108
|
+
cancellation_time: str | None = None
|
|
109
|
+
transaction_info: TransactionInfo | None = None
|
|
110
|
+
creation_time: str | None = None
|
|
111
|
+
status: str | None = None
|
|
112
|
+
deposit_breakdown: list = []
|
|
113
|
+
membership_savings: int | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
"Decorator",
|
|
118
|
+
"Slot",
|
|
119
|
+
"SelectedSlot",
|
|
120
|
+
"Eta",
|
|
121
|
+
"TransactionInfo",
|
|
122
|
+
"OrderArticle",
|
|
123
|
+
"OrderLine",
|
|
124
|
+
"Order",
|
|
125
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Delivery models, parsed from the ``/deliveries/*`` and ``/cart/delivery_slots``
|
|
2
|
+
domain-JSON endpoints.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base import PicnicModel
|
|
6
|
+
from .common import Eta, Order, SelectedSlot, Slot
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DeliverySlots(PicnicModel):
|
|
10
|
+
"""The available delivery slots, returned by ``get_delivery_slots``."""
|
|
11
|
+
|
|
12
|
+
delivery_slots: list[Slot] = []
|
|
13
|
+
selected_slot: SelectedSlot | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Delivery(PicnicModel):
|
|
17
|
+
"""A single delivery in full detail, returned by ``get_delivery``.
|
|
18
|
+
|
|
19
|
+
Contains the full order tree (orders -> lines -> articles), the slot and,
|
|
20
|
+
once en route, an ETA.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
type: str | None = None
|
|
24
|
+
delivery_id: str | None = None
|
|
25
|
+
creation_time: str | None = None
|
|
26
|
+
slot: Slot | None = None
|
|
27
|
+
eta2: Eta | None = None
|
|
28
|
+
status: str | None = None
|
|
29
|
+
orders: list[Order] = []
|
|
30
|
+
returned_containers: list = []
|
|
31
|
+
parcels: list = []
|
|
32
|
+
id: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DeliverySummary(PicnicModel):
|
|
36
|
+
"""A lightweight delivery from ``/deliveries/summary``.
|
|
37
|
+
|
|
38
|
+
Same shape as :class:`Delivery` but the orders are summaries (totals + status,
|
|
39
|
+
no line items). Returned by ``get_deliveries`` / ``get_current_deliveries``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
delivery_id: str | None = None
|
|
43
|
+
creation_time: str | None = None
|
|
44
|
+
slot: Slot | None = None
|
|
45
|
+
eta2: Eta | None = None
|
|
46
|
+
status: str | None = None
|
|
47
|
+
orders: list[Order] = []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["DeliverySlots", "Delivery", "DeliverySummary"]
|