bilkatogo 0.2.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.
- bilkatogo/__init__.py +58 -0
- bilkatogo/__main__.py +21 -0
- bilkatogo/cli.py +398 -0
- bilkatogo/client.py +294 -0
- bilkatogo/config.py +31 -0
- bilkatogo/const.py +23 -0
- bilkatogo/exceptions.py +22 -0
- bilkatogo/models/__init__.py +14 -0
- bilkatogo/models/base.py +18 -0
- bilkatogo/models/money.py +6 -0
- bilkatogo/models/order.py +166 -0
- bilkatogo/models/order_details.py +187 -0
- bilkatogo/models/product.py +77 -0
- bilkatogo/py.typed +0 -0
- bilkatogo/token_storage.py +40 -0
- bilkatogo-0.2.0.dist-info/METADATA +258 -0
- bilkatogo-0.2.0.dist-info/RECORD +20 -0
- bilkatogo-0.2.0.dist-info/WHEEL +4 -0
- bilkatogo-0.2.0.dist-info/entry_points.txt +3 -0
- bilkatogo-0.2.0.dist-info/licenses/LICENSE +21 -0
bilkatogo/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from .client import BilkaToGoClient
|
|
4
|
+
from .const import CURRENT_ORDER_SCAN_LIMIT
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
BilkaToGoError,
|
|
7
|
+
BilkaUnreachableError,
|
|
8
|
+
InvalidCredentialsError,
|
|
9
|
+
UnexpectedResponseError,
|
|
10
|
+
UnknownPickupPointError,
|
|
11
|
+
)
|
|
12
|
+
from .models import (
|
|
13
|
+
FINISHED_STATUSES,
|
|
14
|
+
MissingItem,
|
|
15
|
+
Offer,
|
|
16
|
+
Order,
|
|
17
|
+
OrderDetails,
|
|
18
|
+
OrderLine,
|
|
19
|
+
OrderStatus,
|
|
20
|
+
PickupCode,
|
|
21
|
+
PickupPoint,
|
|
22
|
+
PickupSlot,
|
|
23
|
+
Product,
|
|
24
|
+
Substitution,
|
|
25
|
+
current_order,
|
|
26
|
+
)
|
|
27
|
+
from .token_storage import FileTokenStorage, TokenStorage
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
__version__ = version("bilkatogo")
|
|
31
|
+
except PackageNotFoundError:
|
|
32
|
+
__version__ = "0.0.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"CURRENT_ORDER_SCAN_LIMIT",
|
|
36
|
+
"FINISHED_STATUSES",
|
|
37
|
+
"BilkaToGoClient",
|
|
38
|
+
"BilkaToGoError",
|
|
39
|
+
"BilkaUnreachableError",
|
|
40
|
+
"FileTokenStorage",
|
|
41
|
+
"InvalidCredentialsError",
|
|
42
|
+
"MissingItem",
|
|
43
|
+
"Offer",
|
|
44
|
+
"Order",
|
|
45
|
+
"OrderDetails",
|
|
46
|
+
"OrderLine",
|
|
47
|
+
"OrderStatus",
|
|
48
|
+
"PickupCode",
|
|
49
|
+
"PickupPoint",
|
|
50
|
+
"PickupSlot",
|
|
51
|
+
"Product",
|
|
52
|
+
"Substitution",
|
|
53
|
+
"TokenStorage",
|
|
54
|
+
"UnexpectedResponseError",
|
|
55
|
+
"UnknownPickupPointError",
|
|
56
|
+
"__version__",
|
|
57
|
+
"current_order",
|
|
58
|
+
]
|
bilkatogo/__main__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
MISSING_CLI_EXTRA = (
|
|
4
|
+
"The bilkatogo command needs the CLI extra: pip install 'bilkatogo[cli]' "
|
|
5
|
+
"or uvx --from 'bilkatogo[cli]' bilkatogo"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
try:
|
|
11
|
+
from .cli import main as cli_main
|
|
12
|
+
except ModuleNotFoundError as err:
|
|
13
|
+
if err.name != "typer":
|
|
14
|
+
raise
|
|
15
|
+
print(MISSING_CLI_EXTRA, file=sys.stderr)
|
|
16
|
+
sys.exit(1)
|
|
17
|
+
cli_main()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
main()
|
bilkatogo/cli.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Awaitable, Callable, Coroutine
|
|
6
|
+
from datetime import date, datetime, time
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from . import config
|
|
14
|
+
from .client import BilkaToGoClient
|
|
15
|
+
from .const import SEARCH_LIMIT
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
BilkaToGoError,
|
|
18
|
+
BilkaUnreachableError,
|
|
19
|
+
InvalidCredentialsError,
|
|
20
|
+
UnexpectedResponseError,
|
|
21
|
+
)
|
|
22
|
+
from .models import BilkaDataClass, Offer, Order, OrderStatus, PickupPoint, PickupSlot, Product
|
|
23
|
+
from .token_storage import FileTokenStorage
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(no_args_is_help=True, help="Track your BilkaToGo orders.")
|
|
26
|
+
|
|
27
|
+
EmailOption = Annotated[
|
|
28
|
+
str | None, typer.Option("--email", envvar="BILKATOGO_EMAIL", help="BilkaToGo account email.")
|
|
29
|
+
]
|
|
30
|
+
PasswordOption = Annotated[
|
|
31
|
+
str | None,
|
|
32
|
+
typer.Option("--password", envvar="BILKATOGO_PASSWORD", help="BilkaToGo account password."),
|
|
33
|
+
]
|
|
34
|
+
JsonOption = Annotated[bool, typer.Option("--json", help="Output JSON.")]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_async[**P, R](func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, R]:
|
|
38
|
+
@functools.wraps(func)
|
|
39
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
40
|
+
loop_factory = asyncio.SelectorEventLoop if sys.platform == "win32" else None
|
|
41
|
+
return asyncio.run(func(*args, **kwargs), loop_factory=loop_factory)
|
|
42
|
+
|
|
43
|
+
return wrapper
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def known_credentials(email: str | None, password: str | None) -> tuple[str | None, str | None]:
|
|
47
|
+
saved = config.load_config()
|
|
48
|
+
return email or saved.get("email"), password or saved.get("password")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_credentials(email: str | None, password: str | None) -> tuple[str, str]:
|
|
52
|
+
email, password = known_credentials(email, password)
|
|
53
|
+
return email or typer.prompt("Email"), password or prompt_password()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def prompt_password() -> str:
|
|
57
|
+
return typer.prompt("Password", hide_input=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def to_json(data: Any) -> str:
|
|
61
|
+
def default(obj: Any) -> Any:
|
|
62
|
+
if isinstance(obj, BilkaDataClass):
|
|
63
|
+
return dict(obj)
|
|
64
|
+
if isinstance(obj, datetime | date | time):
|
|
65
|
+
return obj.isoformat()
|
|
66
|
+
if isinstance(obj, Decimal):
|
|
67
|
+
return str(obj)
|
|
68
|
+
raise TypeError(f"Cannot serialise {type(obj).__name__}")
|
|
69
|
+
|
|
70
|
+
return json.dumps(data, default=default, ensure_ascii=False)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def print_table(headers: list[str], rows: list[list[str]]) -> None:
|
|
74
|
+
widths = [max(len(cell) for cell in column) for column in zip(headers, *rows, strict=True)]
|
|
75
|
+
for row in [headers, *rows]:
|
|
76
|
+
typer.echo(" ".join(cell.ljust(width) for cell, width in zip(row, widths, strict=True)))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def http_client(ctx: typer.Context) -> httpx.AsyncClient | None:
|
|
80
|
+
factory = ctx.obj
|
|
81
|
+
return factory() if callable(factory) else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def fail(message: str) -> typer.Exit:
|
|
85
|
+
typer.echo(message, err=True)
|
|
86
|
+
return typer.Exit(code=1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def failure(action: str, err: BilkaToGoError) -> typer.Exit:
|
|
90
|
+
if isinstance(err, InvalidCredentialsError):
|
|
91
|
+
return fail("Login failed: Wrong email or password.")
|
|
92
|
+
if isinstance(err, BilkaUnreachableError):
|
|
93
|
+
return fail(f"{action}: Could not reach Bilka. Check your connection and try again.")
|
|
94
|
+
if isinstance(err, UnexpectedResponseError):
|
|
95
|
+
return fail(f"{action}: Bilka answered unexpectedly ({err}). Try again later.")
|
|
96
|
+
return fail(f"{action}: {err}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.callback()
|
|
100
|
+
def main_callback() -> None:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
@run_async
|
|
106
|
+
async def login(
|
|
107
|
+
ctx: typer.Context,
|
|
108
|
+
email: EmailOption = None,
|
|
109
|
+
password: PasswordOption = None,
|
|
110
|
+
as_json: JsonOption = False,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Log in and store the session for later commands."""
|
|
113
|
+
email, password = resolve_credentials(email, password)
|
|
114
|
+
storage = FileTokenStorage(config.session_file())
|
|
115
|
+
http = http_client(ctx)
|
|
116
|
+
try:
|
|
117
|
+
async with BilkaToGoClient(
|
|
118
|
+
email, password, token_storage=storage, httpx_client=http
|
|
119
|
+
) as client:
|
|
120
|
+
await client.login()
|
|
121
|
+
except BilkaToGoError as err:
|
|
122
|
+
raise failure("Login failed", err) from None
|
|
123
|
+
finally:
|
|
124
|
+
if http is not None:
|
|
125
|
+
await http.aclose()
|
|
126
|
+
|
|
127
|
+
if as_json:
|
|
128
|
+
typer.echo(json.dumps({"email": email, "logged_in": True}))
|
|
129
|
+
else:
|
|
130
|
+
typer.echo(f"Logged in as {email}.")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def with_session[T](
|
|
134
|
+
ctx: typer.Context,
|
|
135
|
+
email: str | None,
|
|
136
|
+
password: str | None,
|
|
137
|
+
fetch: Callable[[BilkaToGoClient], Awaitable[T]],
|
|
138
|
+
what: str,
|
|
139
|
+
) -> T:
|
|
140
|
+
storage = FileTokenStorage(config.session_file())
|
|
141
|
+
email, password = known_credentials(email, password)
|
|
142
|
+
if email is None:
|
|
143
|
+
session = await storage.load()
|
|
144
|
+
email = (session or {}).get("email") or typer.prompt("Email")
|
|
145
|
+
http = http_client(ctx)
|
|
146
|
+
try:
|
|
147
|
+
try:
|
|
148
|
+
async with BilkaToGoClient(
|
|
149
|
+
email, password, token_storage=storage, httpx_client=http
|
|
150
|
+
) as client:
|
|
151
|
+
return await fetch(client)
|
|
152
|
+
except InvalidCredentialsError:
|
|
153
|
+
if password is not None:
|
|
154
|
+
raise
|
|
155
|
+
async with BilkaToGoClient(
|
|
156
|
+
email, prompt_password(), token_storage=storage, httpx_client=http
|
|
157
|
+
) as client:
|
|
158
|
+
return await fetch(client)
|
|
159
|
+
except BilkaToGoError as err:
|
|
160
|
+
raise failure(f"Could not fetch {what}", err) from None
|
|
161
|
+
finally:
|
|
162
|
+
if http is not None:
|
|
163
|
+
await http.aclose()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
LimitOption = Annotated[int, typer.Option("--limit", min=1, help="Number of orders to show.")]
|
|
167
|
+
WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command()
|
|
171
|
+
@run_async
|
|
172
|
+
async def orders(
|
|
173
|
+
ctx: typer.Context,
|
|
174
|
+
limit: LimitOption = 5,
|
|
175
|
+
email: EmailOption = None,
|
|
176
|
+
password: PasswordOption = None,
|
|
177
|
+
as_json: JsonOption = False,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Show your Orders with their Pickup Slot and Order Status."""
|
|
180
|
+
found = await with_session(
|
|
181
|
+
ctx, email, password, lambda client: client.get_orders(limit=limit), "orders"
|
|
182
|
+
)
|
|
183
|
+
if as_json:
|
|
184
|
+
typer.echo(to_json(found))
|
|
185
|
+
elif not found:
|
|
186
|
+
typer.echo("No orders.")
|
|
187
|
+
else:
|
|
188
|
+
print_table(
|
|
189
|
+
["Order", "Ordered", "Pickup Slot", "Pickup Point", "Status"],
|
|
190
|
+
[order_row(order) for order in found],
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def order_row(order: Order) -> list[str]:
|
|
195
|
+
return [
|
|
196
|
+
order.order_number,
|
|
197
|
+
order.ordered_at.strftime("%Y-%m-%d %H:%M") if order.ordered_at else "",
|
|
198
|
+
format_slot(order.pickup_slot),
|
|
199
|
+
format_point(order.pickup_point, order.home_delivery),
|
|
200
|
+
format_status(order.status),
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def format_slot(slot: PickupSlot | None) -> str:
|
|
205
|
+
if slot is None:
|
|
206
|
+
return ""
|
|
207
|
+
return f"{WEEKDAYS[slot.date.weekday()]} {slot.date:%d/%m} {slot.start:%H:%M}-{slot.end:%H:%M}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def format_point(point: PickupPoint | None, home_delivery: bool) -> str:
|
|
211
|
+
return "Home Delivery" if home_delivery else (point.name if point else "")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def format_amount(amount: Decimal | None) -> str:
|
|
215
|
+
return "" if amount is None else f"{amount:.2f} kr"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def format_status(status: OrderStatus) -> str:
|
|
219
|
+
return status.name.replace("_", " ").title()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
OrderNumberArgument = Annotated[
|
|
223
|
+
str | None,
|
|
224
|
+
typer.Argument(help="Order Number, e.g. 900000000001. Defaults to the Current Order."),
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@app.command()
|
|
229
|
+
@run_async
|
|
230
|
+
async def order(
|
|
231
|
+
ctx: typer.Context,
|
|
232
|
+
order_number: OrderNumberArgument = None,
|
|
233
|
+
email: EmailOption = None,
|
|
234
|
+
password: PasswordOption = None,
|
|
235
|
+
as_json: JsonOption = False,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Show an Order (default: the Current Order), its Order Total and what packing changed."""
|
|
238
|
+
if order_number is None:
|
|
239
|
+
details = await with_session(
|
|
240
|
+
ctx, email, password, lambda client: client.get_current_order(), "the current order"
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
details = await with_session(
|
|
244
|
+
ctx,
|
|
245
|
+
email,
|
|
246
|
+
password,
|
|
247
|
+
lambda client: client.get_order(order_number),
|
|
248
|
+
f"order {order_number}",
|
|
249
|
+
)
|
|
250
|
+
if as_json:
|
|
251
|
+
typer.echo(to_json(details))
|
|
252
|
+
return
|
|
253
|
+
if details is None:
|
|
254
|
+
typer.echo("No orders.")
|
|
255
|
+
return
|
|
256
|
+
fields = [
|
|
257
|
+
("Order", details.order_number),
|
|
258
|
+
("Status", format_status(details.status)),
|
|
259
|
+
("Pickup Slot", format_slot(details.pickup_slot)),
|
|
260
|
+
("Pickup Point", format_point(details.pickup_point, details.home_delivery)),
|
|
261
|
+
("Pickup Code", details.pickup_code.code if details.pickup_code else ""),
|
|
262
|
+
("Ordered Total", format_amount(details.ordered_total)),
|
|
263
|
+
("Order Total", format_amount(details.order_total)),
|
|
264
|
+
("Deposit", format_amount(details.deposit)),
|
|
265
|
+
("Service Fee", format_amount(details.service_fee)),
|
|
266
|
+
("Packaging", format_amount(details.packaging)),
|
|
267
|
+
("Delivery Fee", format_amount(details.delivery_fee)),
|
|
268
|
+
("Discount", format_amount(details.discount)),
|
|
269
|
+
]
|
|
270
|
+
for label, value in fields:
|
|
271
|
+
typer.echo(f"{label:<13} {value}")
|
|
272
|
+
print_list(
|
|
273
|
+
"Substitutions",
|
|
274
|
+
[
|
|
275
|
+
f"{substitution.original_product} -> "
|
|
276
|
+
f"{substitution.quantity} x {substitution.replacement_product}"
|
|
277
|
+
for substitution in details.substitutions
|
|
278
|
+
],
|
|
279
|
+
)
|
|
280
|
+
print_list(
|
|
281
|
+
"Missing Items",
|
|
282
|
+
[
|
|
283
|
+
f"{item.product}: {item.missing_quantity} of {item.ordered_quantity} missing"
|
|
284
|
+
for item in details.missing_items
|
|
285
|
+
],
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def print_list(label: str, entries: list[str]) -> None:
|
|
290
|
+
if not entries:
|
|
291
|
+
typer.echo(f"{label} none")
|
|
292
|
+
return
|
|
293
|
+
typer.echo(label)
|
|
294
|
+
for entry in entries:
|
|
295
|
+
typer.echo(f" {entry}")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@app.command("pickup-points")
|
|
299
|
+
@run_async
|
|
300
|
+
async def pickup_points(
|
|
301
|
+
ctx: typer.Context,
|
|
302
|
+
email: EmailOption = None,
|
|
303
|
+
password: PasswordOption = None,
|
|
304
|
+
as_json: JsonOption = False,
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Show every Pickup Point with its Store, marking the one chosen in your Cart."""
|
|
307
|
+
|
|
308
|
+
async def fetch(client: BilkaToGoClient) -> tuple[list[PickupPoint], PickupPoint | None]:
|
|
309
|
+
return await client.get_pickup_points(), await client.get_cart_pickup_point()
|
|
310
|
+
|
|
311
|
+
points, chosen = await with_session(ctx, email, password, fetch, "pickup points")
|
|
312
|
+
chosen_name = chosen.name if chosen else None
|
|
313
|
+
if as_json:
|
|
314
|
+
typer.echo(
|
|
315
|
+
to_json([{**dict(point), "chosen": point.name == chosen_name} for point in points])
|
|
316
|
+
)
|
|
317
|
+
return
|
|
318
|
+
print_table(
|
|
319
|
+
["", "Pickup Point", "Address", "Store"],
|
|
320
|
+
[
|
|
321
|
+
[
|
|
322
|
+
"*" if point.name == chosen_name else "",
|
|
323
|
+
point.name,
|
|
324
|
+
f"{point.address}, {point.zip_code} {point.city}",
|
|
325
|
+
point.store_id or "",
|
|
326
|
+
]
|
|
327
|
+
for point in points
|
|
328
|
+
],
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
SearchTextArgument = Annotated[str, typer.Argument(help="What to search for, e.g. skyr.")]
|
|
333
|
+
PickupPointOption = Annotated[
|
|
334
|
+
str | None,
|
|
335
|
+
typer.Option(
|
|
336
|
+
"--pickup-point",
|
|
337
|
+
help="Search at this Pickup Point's Store. Defaults to the Pickup Point in your Cart.",
|
|
338
|
+
),
|
|
339
|
+
]
|
|
340
|
+
SearchLimitOption = Annotated[
|
|
341
|
+
int, typer.Option("--limit", min=1, help="Number of Products to show.")
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@app.command()
|
|
346
|
+
@run_async
|
|
347
|
+
async def search(
|
|
348
|
+
ctx: typer.Context,
|
|
349
|
+
text: SearchTextArgument,
|
|
350
|
+
pickup_point: PickupPointOption = None,
|
|
351
|
+
limit: SearchLimitOption = SEARCH_LIMIT,
|
|
352
|
+
email: EmailOption = None,
|
|
353
|
+
password: PasswordOption = None,
|
|
354
|
+
as_json: JsonOption = False,
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Search Products with their Price, stock and Offer at a Store."""
|
|
357
|
+
found = await with_session(
|
|
358
|
+
ctx,
|
|
359
|
+
email,
|
|
360
|
+
password,
|
|
361
|
+
lambda client: client.search(text, pickup_point=pickup_point, limit=limit),
|
|
362
|
+
"products",
|
|
363
|
+
)
|
|
364
|
+
if as_json:
|
|
365
|
+
typer.echo(to_json(found))
|
|
366
|
+
elif not found:
|
|
367
|
+
typer.echo("No products.")
|
|
368
|
+
else:
|
|
369
|
+
print_table(
|
|
370
|
+
["Article", "Product", "Brand", "Price", "Unit Price", "Stock", "Offer"],
|
|
371
|
+
[product_row(product) for product in found],
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def product_row(product: Product) -> list[str]:
|
|
376
|
+
unit_price = f"{format_amount(product.unit_price)}/{product.unit}" if product.unit else ""
|
|
377
|
+
return [
|
|
378
|
+
product.article_number,
|
|
379
|
+
product.name,
|
|
380
|
+
product.brand,
|
|
381
|
+
format_amount(product.price),
|
|
382
|
+
unit_price,
|
|
383
|
+
"In stock" if product.in_stock else "Out of stock",
|
|
384
|
+
format_offer(product.offer) if product.offer else product.multibuy_offer or "",
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def format_offer(offer: Offer) -> str:
|
|
389
|
+
text = f"{offer.label}: {format_amount(offer.price)}"
|
|
390
|
+
if offer.ends:
|
|
391
|
+
text += f" until {offer.ends.isoformat()}"
|
|
392
|
+
if offer.limit_per_customer:
|
|
393
|
+
text += f", max {offer.limit_per_customer} per customer"
|
|
394
|
+
return text
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def main() -> None:
|
|
398
|
+
app()
|