stapel-orders 0.1.0__tar.gz
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.
- stapel_orders-0.1.0/CONFIG.MD +27 -0
- stapel_orders-0.1.0/LICENSE +21 -0
- stapel_orders-0.1.0/PKG-INFO +157 -0
- stapel_orders-0.1.0/README.md +122 -0
- stapel_orders-0.1.0/__init__.py +31 -0
- stapel_orders-0.1.0/_capabilities.py +36 -0
- stapel_orders-0.1.0/_codegen.py +120 -0
- stapel_orders-0.1.0/_codegen_settings.py +104 -0
- stapel_orders-0.1.0/actions.py +69 -0
- stapel_orders-0.1.0/admin.py +87 -0
- stapel_orders-0.1.0/apps.py +23 -0
- stapel_orders-0.1.0/beat.py +47 -0
- stapel_orders-0.1.0/checks.py +101 -0
- stapel_orders-0.1.0/codegen_urls.py +6 -0
- stapel_orders-0.1.0/conf.py +58 -0
- stapel_orders-0.1.0/conftest.py +117 -0
- stapel_orders-0.1.0/docs/capabilities.json +165 -0
- stapel_orders-0.1.0/docs/errors.json +467 -0
- stapel_orders-0.1.0/docs/flows.json +215 -0
- stapel_orders-0.1.0/docs/llms.txt +140 -0
- stapel_orders-0.1.0/dto.py +99 -0
- stapel_orders-0.1.0/errors.py +59 -0
- stapel_orders-0.1.0/flows.py +58 -0
- stapel_orders-0.1.0/fsm.py +67 -0
- stapel_orders-0.1.0/functions.py +84 -0
- stapel_orders-0.1.0/gdpr.py +127 -0
- stapel_orders-0.1.0/management/__init__.py +0 -0
- stapel_orders-0.1.0/management/commands/__init__.py +0 -0
- stapel_orders-0.1.0/management/commands/purge_carts.py +14 -0
- stapel_orders-0.1.0/migrations/0001_initial.py +113 -0
- stapel_orders-0.1.0/migrations/__init__.py +0 -0
- stapel_orders-0.1.0/models.py +320 -0
- stapel_orders-0.1.0/money.py +119 -0
- stapel_orders-0.1.0/payment.py +120 -0
- stapel_orders-0.1.0/presenters.py +137 -0
- stapel_orders-0.1.0/py.typed +0 -0
- stapel_orders-0.1.0/pyproject.toml +89 -0
- stapel_orders-0.1.0/registry.py +191 -0
- stapel_orders-0.1.0/schemas/consumes/payment.completed.json +15 -0
- stapel_orders-0.1.0/schemas/consumes/user.merged.json +12 -0
- stapel_orders-0.1.0/schemas/emits/orders.order.paid.json +12 -0
- stapel_orders-0.1.0/schemas/emits/orders.order.placed.json +31 -0
- stapel_orders-0.1.0/schemas/emits/orders.order.status_changed.json +16 -0
- stapel_orders-0.1.0/schemas/functions/orders.cart.count.json +11 -0
- stapel_orders-0.1.0/schemas/functions/orders.order.status.json +11 -0
- stapel_orders-0.1.0/schemas/functions/orders.orders_export.json +11 -0
- stapel_orders-0.1.0/schemas/functions/purchasables.json +37 -0
- stapel_orders-0.1.0/serializers.py +159 -0
- stapel_orders-0.1.0/services/__init__.py +20 -0
- stapel_orders-0.1.0/services/cart.py +274 -0
- stapel_orders-0.1.0/services/checkout.py +290 -0
- stapel_orders-0.1.0/services/events.py +119 -0
- stapel_orders-0.1.0/services/orders.py +153 -0
- stapel_orders-0.1.0/setup.cfg +4 -0
- stapel_orders-0.1.0/stapel_orders.egg-info/PKG-INFO +157 -0
- stapel_orders-0.1.0/stapel_orders.egg-info/SOURCES.txt +128 -0
- stapel_orders-0.1.0/stapel_orders.egg-info/dependency_links.txt +1 -0
- stapel_orders-0.1.0/stapel_orders.egg-info/requires.txt +10 -0
- stapel_orders-0.1.0/stapel_orders.egg-info/top_level.txt +1 -0
- stapel_orders-0.1.0/tests/test_architecture.py +47 -0
- stapel_orders-0.1.0/tests/test_beat.py +33 -0
- stapel_orders-0.1.0/tests/test_cart.py +263 -0
- stapel_orders-0.1.0/tests/test_checkout.py +296 -0
- stapel_orders-0.1.0/tests/test_checks.py +112 -0
- stapel_orders-0.1.0/tests/test_concurrency.py +78 -0
- stapel_orders-0.1.0/tests/test_contract.py +120 -0
- stapel_orders-0.1.0/tests/test_contract_wire.py +201 -0
- stapel_orders-0.1.0/tests/test_fsm.py +61 -0
- stapel_orders-0.1.0/tests/test_functions.py +49 -0
- stapel_orders-0.1.0/tests/test_gdpr.py +62 -0
- stapel_orders-0.1.0/tests/test_merge.py +89 -0
- stapel_orders-0.1.0/tests/test_money_bounds.py +84 -0
- stapel_orders-0.1.0/tests/test_orders_api.py +123 -0
- stapel_orders-0.1.0/tests/test_payment.py +73 -0
- stapel_orders-0.1.0/tests/test_public_api.py +46 -0
- stapel_orders-0.1.0/urls.py +14 -0
- stapel_orders-0.1.0/urls_v1.py +45 -0
- stapel_orders-0.1.0/views.py +284 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# CONFIG.MD — stapel-orders
|
|
2
|
+
|
|
3
|
+
Config registry for **stapel-orders**. One row per configuration key the
|
|
4
|
+
module reads, its **source** (`env` = the process environment / the
|
|
5
|
+
`STAPEL_ORDERS` settings dict), what it is for, whether it is required, and
|
|
6
|
+
its default.
|
|
7
|
+
|
|
8
|
+
Every key is read through `orders_settings` (`stapel_orders.conf`, namespace
|
|
9
|
+
`STAPEL_ORDERS`). Resolution order per key: `settings.STAPEL_ORDERS` dict →
|
|
10
|
+
a flat Django setting of the same name → environment variable → the default
|
|
11
|
+
below. `CHECKOUT_REQUIRES_ACCOUNT` and `PAYMENT` are CTO-facing config axes.
|
|
12
|
+
|
|
13
|
+
## stapel-orders
|
|
14
|
+
|
|
15
|
+
| Key | Source | Purpose | Required | Default |
|
|
16
|
+
|-----|--------|---------|----------|---------|
|
|
17
|
+
| PURCHASABLE_TYPES | settings | what can be bought: {type_name: {"resolve": "<comm function>"}} merged over the empty built-ins; None removes a type. Empty is system check E001. | yes | {} |
|
|
18
|
+
| CHECKOUT_REQUIRES_ACCOUNT | env | axis: a guest (anonymous account) is refused at checkout with `error.403.checkout_requires_account`. Not derived from the posture. | no | true |
|
|
19
|
+
| MAX_LINE_QUANTITY | env | cap on one line's quantity; feeds the money-bound proof (E005). | no | 999 |
|
|
20
|
+
| MAX_LINES_PER_CART | env | cap on distinct lines per cart; feeds the money-bound proof (E005). | no | 100 |
|
|
21
|
+
| CART_TTL_DAYS | env | carts untouched this long are purged (beat entry / `manage.py purge_carts`). | no | 30 |
|
|
22
|
+
| BASE_CURRENCY | env | reporting currency of `Order.total_base`. | no | USD |
|
|
23
|
+
| TOTAL_BASE_CONVERTER | settings | dotted path `(amount, currency, base) -> Decimal or None` computing `total_base`; default asks `currencies.convert`, NULL when unanswered. | no | stapel_orders.money.currencies_converter |
|
|
24
|
+
| PAYMENT | env | axis: `none` (orders stay unpaid, staff confirm) or `mock` (paid at once; prototype stage only, E002/W002), or a registered provider name; a dotted path only from the settings module. | no | none |
|
|
25
|
+
| OWNER_CANCELLABLE_STATUSES | settings | statuses from which the buyer may cancel their own order. | no | ("new",) |
|
|
26
|
+
| NOTIFY | env | best-effort buyer notifications `orders.placed` / `orders.status_changed`. | no | true |
|
|
27
|
+
| ORDER_URL_TEMPLATE | env | `{order_id}` / `{number}` template for notification links; empty = no link. | no | "" |
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stapel contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stapel-orders
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cart, checkout and orders for the Stapel framework
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/usestapel/stapel-orders
|
|
7
|
+
Project-URL: Repository, https://github.com/usestapel/stapel-orders
|
|
8
|
+
Project-URL: Documentation, https://github.com/usestapel/stapel-orders#readme
|
|
9
|
+
Project-URL: Changelog, https://github.com/usestapel/stapel-orders/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/usestapel/stapel-orders/issues
|
|
11
|
+
Keywords: django,stapel,orders,cart,checkout,ecommerce
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: stapel-core<1.0,>=0.95.0
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Provides-Extra: test
|
|
29
|
+
Requires-Dist: pytest; extra == "test"
|
|
30
|
+
Requires-Dist: pytest-django; extra == "test"
|
|
31
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
32
|
+
Requires-Dist: stapel-tools<1,>=0.76.0; extra == "test"
|
|
33
|
+
Requires-Dist: jsonschema>=4; extra == "test"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
<!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->
|
|
37
|
+
|
|
38
|
+
# stapel-orders
|
|
39
|
+
|
|
40
|
+
[](https://github.com/usestapel/stapel-orders/actions/workflows/ci.yml?query=branch%3Amain)
|
|
41
|
+
[](https://app.codecov.io/gh/usestapel/stapel-orders)
|
|
42
|
+
[](https://pypi.org/project/stapel-orders/)
|
|
43
|
+
[](https://pepy.tech/project/stapel-orders)
|
|
44
|
+
[](https://pypi.org/project/stapel-orders/)
|
|
45
|
+
[](https://github.com/usestapel/stapel-orders/blob/main/LICENSE)
|
|
46
|
+
[](https://github.com/usestapel/stapel-orders/blob/main/docs/llms.txt)
|
|
47
|
+
|
|
48
|
+
> Cart, checkout and orders over opaque purchasables. A host registers what can be bought (purchasable_type -> a keyed-batch resolver Function answering title, price, currency, availability); the cart snapshots each line when it is added, so reading a cart never calls the product owner. Checkout re-prices every line, refuses with the refreshed cart when anything drifted, and places an order idempotently on a client key, emptying the cart and announcing orders.order.placed with its lines. Orders follow a staff-driven lifecycle with buyer cancel; payment is a provider seam (none, mock) whose outcome arrives as the payment.completed fact.
|
|
49
|
+
|
|
50
|
+
Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install stapel-orders
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## At a glance
|
|
59
|
+
|
|
60
|
+
| Fact | Value |
|
|
61
|
+
|---|---|
|
|
62
|
+
| Version | `0.1.0` |
|
|
63
|
+
| Python | `>=3.11` (3.11, 3.12, 3.13, 3.14) |
|
|
64
|
+
| HTTP operations | 10 |
|
|
65
|
+
| Config axes | 2 |
|
|
66
|
+
| Usage surface | 11 |
|
|
67
|
+
| Extension points | 7 |
|
|
68
|
+
| Error codes | 54 |
|
|
69
|
+
| Documented flows | 4 |
|
|
70
|
+
| Fleet dependencies | [`stapel-auth`](https://github.com/usestapel/stapel-auth) (optional) · [`stapel-core`](https://github.com/usestapel/stapel-core) · [`stapel-currencies`](https://github.com/usestapel/stapel-currencies) (optional) · [`stapel-listings`](https://github.com/usestapel/stapel-listings) (optional) |
|
|
71
|
+
|
|
72
|
+
## Documentation
|
|
73
|
+
|
|
74
|
+
[OpenAPI](https://github.com/usestapel/stapel-orders/blob/main/docs/schema.json) · [capabilities.json](https://github.com/usestapel/stapel-orders/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-orders/blob/main/docs/llms.txt)
|
|
75
|
+
|
|
76
|
+
## What this is
|
|
77
|
+
|
|
78
|
+
Cart, checkout and orders for a Stapel shop — over **opaque purchasables**.
|
|
79
|
+
The module ships knowing nothing about what is sold: a host registers each
|
|
80
|
+
purchasable type with a comm Function that resolves a keyed batch of keys to
|
|
81
|
+
title, price, currency and availability. Orders never imports a product
|
|
82
|
+
model, so a listing, a spare part from a custom app, or anything else is
|
|
83
|
+
bought through one cart.
|
|
84
|
+
|
|
85
|
+
## Quick start
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
INSTALLED_APPS = [
|
|
89
|
+
# ...
|
|
90
|
+
"stapel_orders",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
STAPEL_ORDERS = {
|
|
94
|
+
"PURCHASABLE_TYPES": {
|
|
95
|
+
"listing": {"resolve": "listings.purchasables"}, # stapel-shop preset default
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# urls.py
|
|
100
|
+
path("orders/api/", include("stapel_orders.urls")) # -> /orders/api/v1/...
|
|
101
|
+
|
|
102
|
+
# settings.py — the abandoned-cart purge (Celery optional)
|
|
103
|
+
from stapel_orders.beat import get_orders_beat_schedule
|
|
104
|
+
CELERY_BEAT_SCHEDULE = {**get_orders_beat_schedule()}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A custom product type is one resolver and one registration:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from stapel_core.comm import function
|
|
111
|
+
|
|
112
|
+
@function("parts.purchasables")
|
|
113
|
+
def purchasables(payload):
|
|
114
|
+
parts = Part.objects.filter(pk__in=payload["keys"])
|
|
115
|
+
return {str(p.pk): {"title": p.name, "price": str(p.price), "currency": "USD",
|
|
116
|
+
"available": p.stock > 0, "max_quantity": p.stock,
|
|
117
|
+
"seller_id": None, "image": None, "url": f"/parts/{p.pk}"}
|
|
118
|
+
for p in parts}
|
|
119
|
+
|
|
120
|
+
# AppConfig.ready()
|
|
121
|
+
from stapel_orders.registry import register_purchasable_type
|
|
122
|
+
register_purchasable_type("part", {"resolve": "parts.purchasables"})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Concepts
|
|
126
|
+
|
|
127
|
+
- **Purchasable** — an opaque pair `(purchasable_type, purchasable_key)`.
|
|
128
|
+
The resolver answers on add and at checkout only; a cart line is a
|
|
129
|
+
snapshot, so `GET cart/` makes zero comm calls.
|
|
130
|
+
- **Cart** — one per user. A stapel-auth guest is a user too, and
|
|
131
|
+
`user.merged` folds the guest's cart into the account on sign-in (same
|
|
132
|
+
item summed, capped at `MAX_LINE_QUANTITY`). One currency per cart; a line
|
|
133
|
+
in another currency is refused, never converted.
|
|
134
|
+
- **Checkout** — one idempotent call (`idempotency_key`). Every line is
|
|
135
|
+
re-priced; a changed price, a vanished item or short stock refreshes the
|
|
136
|
+
cart and refuses with it in `params.cart`, so the shopper is never charged
|
|
137
|
+
a number they did not see. Guests are asked to sign in unless
|
|
138
|
+
`CHECKOUT_REQUIRES_ACCOUNT` is off.
|
|
139
|
+
- **Order** — `new → confirmed → shipped → completed`, cancel from `new` by
|
|
140
|
+
the buyer (`OWNER_CANCELLABLE_STATUSES`), staff for the rest, `needs_review`
|
|
141
|
+
when a payment does not match. History is anchor-paginated.
|
|
142
|
+
- **Money** — `Decimal` inside, strings on the wire. Line and cart limits
|
|
143
|
+
are a proof that a total cannot overflow its column
|
|
144
|
+
(`tests/test_money_bounds.py`, system check `E005`).
|
|
145
|
+
- **Payment** — a provider seam: `none` (staff confirm by hand) or `mock`
|
|
146
|
+
(prototype posture only). The outcome always arrives as
|
|
147
|
+
`payment.completed`, the one path that marks an order paid.
|
|
148
|
+
- **GDPR** — erasure deletes carts and pseudonymizes the person on orders;
|
|
149
|
+
amounts stay.
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT — see [LICENSE](https://github.com/usestapel/stapel-orders/blob/main/LICENSE).
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
<sub>This page is assembled by `stapel-readme` from `docs/readme.md` plus the contract artifacts in `docs/`. Edit the prose in `docs/readme.md`; the badges, facts and links above and below it are generated — do not hand-edit `README.md`.</sub>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->
|
|
2
|
+
|
|
3
|
+
# stapel-orders
|
|
4
|
+
|
|
5
|
+
[](https://github.com/usestapel/stapel-orders/actions/workflows/ci.yml?query=branch%3Amain)
|
|
6
|
+
[](https://app.codecov.io/gh/usestapel/stapel-orders)
|
|
7
|
+
[](https://pypi.org/project/stapel-orders/)
|
|
8
|
+
[](https://pepy.tech/project/stapel-orders)
|
|
9
|
+
[](https://pypi.org/project/stapel-orders/)
|
|
10
|
+
[](https://github.com/usestapel/stapel-orders/blob/main/LICENSE)
|
|
11
|
+
[](https://github.com/usestapel/stapel-orders/blob/main/docs/llms.txt)
|
|
12
|
+
|
|
13
|
+
> Cart, checkout and orders over opaque purchasables. A host registers what can be bought (purchasable_type -> a keyed-batch resolver Function answering title, price, currency, availability); the cart snapshots each line when it is added, so reading a cart never calls the product owner. Checkout re-prices every line, refuses with the refreshed cart when anything drifted, and places an order idempotently on a client key, emptying the cart and announcing orders.order.placed with its lines. Orders follow a staff-driven lifecycle with buyer cancel; payment is a provider seam (none, mock) whose outcome arrives as the payment.completed fact.
|
|
14
|
+
|
|
15
|
+
Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install stapel-orders
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## At a glance
|
|
24
|
+
|
|
25
|
+
| Fact | Value |
|
|
26
|
+
|---|---|
|
|
27
|
+
| Version | `0.1.0` |
|
|
28
|
+
| Python | `>=3.11` (3.11, 3.12, 3.13, 3.14) |
|
|
29
|
+
| HTTP operations | 10 |
|
|
30
|
+
| Config axes | 2 |
|
|
31
|
+
| Usage surface | 11 |
|
|
32
|
+
| Extension points | 7 |
|
|
33
|
+
| Error codes | 54 |
|
|
34
|
+
| Documented flows | 4 |
|
|
35
|
+
| Fleet dependencies | [`stapel-auth`](https://github.com/usestapel/stapel-auth) (optional) · [`stapel-core`](https://github.com/usestapel/stapel-core) · [`stapel-currencies`](https://github.com/usestapel/stapel-currencies) (optional) · [`stapel-listings`](https://github.com/usestapel/stapel-listings) (optional) |
|
|
36
|
+
|
|
37
|
+
## Documentation
|
|
38
|
+
|
|
39
|
+
[OpenAPI](https://github.com/usestapel/stapel-orders/blob/main/docs/schema.json) · [capabilities.json](https://github.com/usestapel/stapel-orders/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-orders/blob/main/docs/llms.txt)
|
|
40
|
+
|
|
41
|
+
## What this is
|
|
42
|
+
|
|
43
|
+
Cart, checkout and orders for a Stapel shop — over **opaque purchasables**.
|
|
44
|
+
The module ships knowing nothing about what is sold: a host registers each
|
|
45
|
+
purchasable type with a comm Function that resolves a keyed batch of keys to
|
|
46
|
+
title, price, currency and availability. Orders never imports a product
|
|
47
|
+
model, so a listing, a spare part from a custom app, or anything else is
|
|
48
|
+
bought through one cart.
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
INSTALLED_APPS = [
|
|
54
|
+
# ...
|
|
55
|
+
"stapel_orders",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
STAPEL_ORDERS = {
|
|
59
|
+
"PURCHASABLE_TYPES": {
|
|
60
|
+
"listing": {"resolve": "listings.purchasables"}, # stapel-shop preset default
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# urls.py
|
|
65
|
+
path("orders/api/", include("stapel_orders.urls")) # -> /orders/api/v1/...
|
|
66
|
+
|
|
67
|
+
# settings.py — the abandoned-cart purge (Celery optional)
|
|
68
|
+
from stapel_orders.beat import get_orders_beat_schedule
|
|
69
|
+
CELERY_BEAT_SCHEDULE = {**get_orders_beat_schedule()}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A custom product type is one resolver and one registration:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from stapel_core.comm import function
|
|
76
|
+
|
|
77
|
+
@function("parts.purchasables")
|
|
78
|
+
def purchasables(payload):
|
|
79
|
+
parts = Part.objects.filter(pk__in=payload["keys"])
|
|
80
|
+
return {str(p.pk): {"title": p.name, "price": str(p.price), "currency": "USD",
|
|
81
|
+
"available": p.stock > 0, "max_quantity": p.stock,
|
|
82
|
+
"seller_id": None, "image": None, "url": f"/parts/{p.pk}"}
|
|
83
|
+
for p in parts}
|
|
84
|
+
|
|
85
|
+
# AppConfig.ready()
|
|
86
|
+
from stapel_orders.registry import register_purchasable_type
|
|
87
|
+
register_purchasable_type("part", {"resolve": "parts.purchasables"})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Concepts
|
|
91
|
+
|
|
92
|
+
- **Purchasable** — an opaque pair `(purchasable_type, purchasable_key)`.
|
|
93
|
+
The resolver answers on add and at checkout only; a cart line is a
|
|
94
|
+
snapshot, so `GET cart/` makes zero comm calls.
|
|
95
|
+
- **Cart** — one per user. A stapel-auth guest is a user too, and
|
|
96
|
+
`user.merged` folds the guest's cart into the account on sign-in (same
|
|
97
|
+
item summed, capped at `MAX_LINE_QUANTITY`). One currency per cart; a line
|
|
98
|
+
in another currency is refused, never converted.
|
|
99
|
+
- **Checkout** — one idempotent call (`idempotency_key`). Every line is
|
|
100
|
+
re-priced; a changed price, a vanished item or short stock refreshes the
|
|
101
|
+
cart and refuses with it in `params.cart`, so the shopper is never charged
|
|
102
|
+
a number they did not see. Guests are asked to sign in unless
|
|
103
|
+
`CHECKOUT_REQUIRES_ACCOUNT` is off.
|
|
104
|
+
- **Order** — `new → confirmed → shipped → completed`, cancel from `new` by
|
|
105
|
+
the buyer (`OWNER_CANCELLABLE_STATUSES`), staff for the rest, `needs_review`
|
|
106
|
+
when a payment does not match. History is anchor-paginated.
|
|
107
|
+
- **Money** — `Decimal` inside, strings on the wire. Line and cart limits
|
|
108
|
+
are a proof that a total cannot overflow its column
|
|
109
|
+
(`tests/test_money_bounds.py`, system check `E005`).
|
|
110
|
+
- **Payment** — a provider seam: `none` (staff confirm by hand) or `mock`
|
|
111
|
+
(prototype posture only). The outcome always arrives as
|
|
112
|
+
`payment.completed`, the one path that marks an order paid.
|
|
113
|
+
- **GDPR** — erasure deletes carts and pseudonymizes the person on orders;
|
|
114
|
+
amounts stay.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT — see [LICENSE](https://github.com/usestapel/stapel-orders/blob/main/LICENSE).
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
<sub>This page is assembled by `stapel-readme` from `docs/readme.md` plus the contract artifacts in `docs/`. Edit the prose in `docs/readme.md`; the badges, facts and links above and below it are generated — do not hand-edit `README.md`.</sub>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""stapel-orders — Cart, checkout and orders for the Stapel framework.
|
|
2
|
+
|
|
3
|
+
Public API (lazily exported, PEP 562 — importing this package never pulls
|
|
4
|
+
in Django or requires configured settings):
|
|
5
|
+
|
|
6
|
+
- ``orders_settings`` — resolved app settings (``stapel_orders.conf``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"orders_settings",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
# name -> submodule that defines it. Resolution is deferred until first
|
|
14
|
+
# attribute access so that `import stapel_orders` stays Django-free.
|
|
15
|
+
_LAZY_EXPORTS = {
|
|
16
|
+
"orders_settings": ".conf",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __getattr__(name):
|
|
21
|
+
if name in _LAZY_EXPORTS:
|
|
22
|
+
from importlib import import_module
|
|
23
|
+
|
|
24
|
+
value = getattr(import_module(_LAZY_EXPORTS[name], __name__), name)
|
|
25
|
+
globals()[name] = value # cache for subsequent lookups
|
|
26
|
+
return value
|
|
27
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def __dir__():
|
|
31
|
+
return sorted(set(globals()) | set(__all__))
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""stapel-orders capabilities.json emitter — thin shim over stapel_tools.capabilities."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from stapel_tools.capabilities import axis_group_rules, run_capabilities_cli
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main(argv=None):
|
|
8
|
+
from stapel_orders._codegen import _configure
|
|
9
|
+
|
|
10
|
+
_configure()
|
|
11
|
+
from stapel_orders.conf import DEFAULTS
|
|
12
|
+
from stapel_orders.urls import GATE_REGISTRY
|
|
13
|
+
|
|
14
|
+
# Two CTO-facing axes, both behavioural (no endpoint is unmounted):
|
|
15
|
+
# CHECKOUT_REQUIRES_ACCOUNT (guest checkout) and PAYMENT (none | mock |
|
|
16
|
+
# a registered provider). PURCHASABLE_TYPES is the extension seam.
|
|
17
|
+
axes = {"CHECKOUT_REQUIRES_ACCOUNT", "PAYMENT"}
|
|
18
|
+
return run_capabilities_cli(
|
|
19
|
+
argv,
|
|
20
|
+
repo=Path(__file__).resolve().parent,
|
|
21
|
+
canonical_prefix="/orders/api/v1",
|
|
22
|
+
defaults=DEFAULTS,
|
|
23
|
+
registry=GATE_REGISTRY,
|
|
24
|
+
is_axis=lambda k: k in axes,
|
|
25
|
+
axis_group=axis_group_rules(
|
|
26
|
+
exact={
|
|
27
|
+
"CHECKOUT_REQUIRES_ACCOUNT": "orders.checkout",
|
|
28
|
+
"PAYMENT": "orders.payment",
|
|
29
|
+
}
|
|
30
|
+
),
|
|
31
|
+
prog="stapel-orders-capabilities",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""stapel-orders contract-emission harness (contract-pipeline.md §2-3).
|
|
2
|
+
|
|
3
|
+
Emits the module's own contract triad into ``docs/`` from a single-module
|
|
4
|
+
``{orders + core}`` Django instance mounted at the canonical
|
|
5
|
+
``orders/api/v1/`` prefix:
|
|
6
|
+
|
|
7
|
+
docs/schema.json drf-spectacular OpenAPI, this module only, canonical prefix
|
|
8
|
+
docs/flows.json generate_flow_docs machine artifact ([] — no @flow_step here)
|
|
9
|
+
docs/errors.json generate_error_keys registry
|
|
10
|
+
|
|
11
|
+
The *mechanism* is stapel_tools.codegen (shared, unchanged); this file is the
|
|
12
|
+
thin per-module *config* that wires the module's settings + canonical mount.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python -m stapel_orders._codegen --out docs # `make contract`
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _configure() -> None:
|
|
26
|
+
"""Configure + boot the single-module Django instance for emission."""
|
|
27
|
+
repo_root = os.path.dirname(os.path.abspath(__file__))
|
|
28
|
+
sys.path[:] = [p for p in sys.path if os.path.abspath(p or os.getcwd()) != repo_root]
|
|
29
|
+
|
|
30
|
+
from django.conf import settings
|
|
31
|
+
|
|
32
|
+
if not settings.configured:
|
|
33
|
+
from stapel_orders._codegen_settings import settings_kwargs
|
|
34
|
+
|
|
35
|
+
settings.configure(
|
|
36
|
+
**settings_kwargs(root_urlconf="stapel_orders.codegen_urls", contract=True)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
import django
|
|
40
|
+
|
|
41
|
+
django.setup()
|
|
42
|
+
|
|
43
|
+
from drf_spectacular.settings import spectacular_settings
|
|
44
|
+
|
|
45
|
+
from stapel_orders._codegen_settings import CODEGEN_SCHEMA_PATH_PREFIX
|
|
46
|
+
|
|
47
|
+
spectacular_settings.SCHEMA_PATH_PREFIX = CODEGEN_SCHEMA_PATH_PREFIX
|
|
48
|
+
|
|
49
|
+
# Stable enum component names (stapel-schema-lint SCH004).
|
|
50
|
+
from drf_spectacular.plumbing import _load_enum_name_overrides
|
|
51
|
+
|
|
52
|
+
from stapel_orders._codegen_settings import enum_name_overrides
|
|
53
|
+
|
|
54
|
+
spectacular_settings.ENUM_NAME_OVERRIDES = enum_name_overrides()
|
|
55
|
+
_load_enum_name_overrides.cache_clear()
|
|
56
|
+
|
|
57
|
+
# Register drf-spectacular's JWT cookie-auth extension explicitly (a real
|
|
58
|
+
# host triggers this as a side effect of wiring its Swagger URLs). Without
|
|
59
|
+
# it, the protected endpoints
|
|
60
|
+
# would emit without their `security: [{"JWTCookieAuth": []}]` entry — a
|
|
61
|
+
# real contract gap. Applied per stapel-calendar's precedent.
|
|
62
|
+
from stapel_core.django.openapi.swagger import _register_jwt_auth_extension
|
|
63
|
+
|
|
64
|
+
_register_jwt_auth_extension()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _require_python_312() -> None:
|
|
68
|
+
"""Abort emission if not running the pinned 3.12 interpreter.
|
|
69
|
+
|
|
70
|
+
drf-spectacular's rendering of component descriptions (``Optional[X]`` vs
|
|
71
|
+
``X | None``) depends on the Python minor version — contracts emitted on
|
|
72
|
+
anything other than 3.12 (the CI/monolith pin) produce false diffs.
|
|
73
|
+
"""
|
|
74
|
+
if sys.version_info[:2] != (3, 12):
|
|
75
|
+
got = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
76
|
+
raise SystemExit(
|
|
77
|
+
f"stapel-orders contract emission ABORTED: running Python {got}, "
|
|
78
|
+
"but contracts must be emitted on Python 3.12 (the CI/monolith "
|
|
79
|
+
"pin). drf-spectacular renders component descriptions (Optional[X] "
|
|
80
|
+
"vs X | None) differently across Python minor versions, so emitting "
|
|
81
|
+
"on any other minor produces false diffs against the committed "
|
|
82
|
+
"docs/*.json. Re-run under a 3.12 interpreter."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main(argv: list[str] | None = None) -> int:
|
|
87
|
+
_require_python_312()
|
|
88
|
+
|
|
89
|
+
parser = argparse.ArgumentParser(
|
|
90
|
+
prog="stapel-orders-contract",
|
|
91
|
+
description="Emit this module's contract triad (schema.json + flows.json "
|
|
92
|
+
"+ errors.json) into --out, canonical /orders/api/v1/ prefix.",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--out",
|
|
96
|
+
default="docs",
|
|
97
|
+
help="Output directory for the triad (default: docs).",
|
|
98
|
+
)
|
|
99
|
+
args = parser.parse_args(argv)
|
|
100
|
+
|
|
101
|
+
_configure()
|
|
102
|
+
|
|
103
|
+
from stapel_tools.codegen import emit_errors, emit_flows, emit_schema
|
|
104
|
+
|
|
105
|
+
out = Path(args.out)
|
|
106
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
paths = emit_schema(out / "schema.json")
|
|
108
|
+
flows = emit_flows(out / "flows.json")
|
|
109
|
+
errors = emit_errors(out / "errors.json")
|
|
110
|
+
|
|
111
|
+
print(
|
|
112
|
+
f"stapel-orders contract: {paths} paths, {flows} flows, {errors} error keys "
|
|
113
|
+
f"→ {out}/",
|
|
114
|
+
file=sys.stderr,
|
|
115
|
+
)
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Single-module Django settings shared by the test suite and contract emission.
|
|
2
|
+
|
|
3
|
+
One copy, so the harness that emits ``docs/schema.json`` and the tests that
|
|
4
|
+
prove it can never drift in INSTALLED_APPS or comm config. The production
|
|
5
|
+
``REST_FRAMEWORK`` (exception handler, schema class) is used by both: the
|
|
6
|
+
error envelope the tests assert is the one a host serves.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
#: The resolver Function the test catalog provides (tests/catalog.py).
|
|
11
|
+
TEST_RESOLVER = "orders_test.purchasables"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def settings_kwargs(
|
|
15
|
+
*,
|
|
16
|
+
root_urlconf: str = "stapel_orders.tests.urls",
|
|
17
|
+
contract: bool = False,
|
|
18
|
+
) -> dict:
|
|
19
|
+
rest_framework = {
|
|
20
|
+
"DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"],
|
|
21
|
+
"DEFAULT_SCHEMA_CLASS": "stapel_core.django.openapi.schemas.PermissionAwareAutoSchema",
|
|
22
|
+
"EXCEPTION_HANDLER": "stapel_core.django.api.errors.stapel_exception_handler",
|
|
23
|
+
}
|
|
24
|
+
if contract:
|
|
25
|
+
rest_framework.update({
|
|
26
|
+
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
27
|
+
"stapel_core.django.jwt.authentication.JWTCookieAuthentication",
|
|
28
|
+
],
|
|
29
|
+
"DEFAULT_PERMISSION_CLASSES": [
|
|
30
|
+
"stapel_core.django.api.permissions.IsServiceRequest",
|
|
31
|
+
"stapel_core.django.api.permissions.IsSuperUser",
|
|
32
|
+
],
|
|
33
|
+
"DEFAULT_RENDERER_CLASSES": [
|
|
34
|
+
"rest_framework.renderers.JSONRenderer",
|
|
35
|
+
"rest_framework.renderers.BrowsableAPIRenderer",
|
|
36
|
+
],
|
|
37
|
+
})
|
|
38
|
+
return dict(
|
|
39
|
+
SECRET_KEY="test-secret-key-not-for-production",
|
|
40
|
+
INSTALLED_APPS=[
|
|
41
|
+
"django.contrib.contenttypes",
|
|
42
|
+
"django.contrib.auth",
|
|
43
|
+
"django.contrib.sessions",
|
|
44
|
+
"django.contrib.admin",
|
|
45
|
+
"django.contrib.messages",
|
|
46
|
+
"stapel_core.django.apps.CommonDjangoConfig",
|
|
47
|
+
"stapel_core.django.users",
|
|
48
|
+
"rest_framework",
|
|
49
|
+
"drf_spectacular",
|
|
50
|
+
"stapel_orders",
|
|
51
|
+
],
|
|
52
|
+
AUTH_USER_MODEL="users.User",
|
|
53
|
+
DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
|
|
54
|
+
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
|
|
55
|
+
USE_TZ=True,
|
|
56
|
+
ROOT_URLCONF=root_urlconf,
|
|
57
|
+
CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}},
|
|
58
|
+
STAPEL_BUS_BACKEND="stapel_core.bus.backends.memory.MemoryBus",
|
|
59
|
+
STAPEL_COMM={
|
|
60
|
+
"OUTBOX_ENABLED": False,
|
|
61
|
+
"ACTION_TRANSPORT": "inprocess",
|
|
62
|
+
"VALIDATE_SCHEMAS": True,
|
|
63
|
+
},
|
|
64
|
+
STAPEL_ORDERS={
|
|
65
|
+
"PURCHASABLE_TYPES": {"item": {"resolve": TEST_RESOLVER}},
|
|
66
|
+
},
|
|
67
|
+
TEMPLATES=[{
|
|
68
|
+
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
69
|
+
"APP_DIRS": True,
|
|
70
|
+
"OPTIONS": {"context_processors": [
|
|
71
|
+
"django.template.context_processors.request",
|
|
72
|
+
"django.contrib.auth.context_processors.auth",
|
|
73
|
+
"django.contrib.messages.context_processors.messages",
|
|
74
|
+
]},
|
|
75
|
+
}],
|
|
76
|
+
MIDDLEWARE=[
|
|
77
|
+
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
78
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
79
|
+
"django.contrib.messages.middleware.MessageMiddleware",
|
|
80
|
+
],
|
|
81
|
+
MIGRATION_MODULES={"users": None},
|
|
82
|
+
REST_FRAMEWORK=rest_framework,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
#: Forced on drf-spectacular's singleton so a single-module instance derives
|
|
87
|
+
#: the same operation ids as a multi-module aggregate.
|
|
88
|
+
CODEGEN_SCHEMA_PATH_PREFIX = "/"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def enum_name_overrides() -> dict:
|
|
92
|
+
"""Stable enum component names (stapel-schema-lint SCH004).
|
|
93
|
+
|
|
94
|
+
``OrderStatus`` is carried by ``OrderResponse.status`` and
|
|
95
|
+
``OrderTransitionRequest.to``; naming both sets keeps the react pair's
|
|
96
|
+
generated types from being renamed by an unrelated edit. Called after
|
|
97
|
+
``django.setup()``.
|
|
98
|
+
"""
|
|
99
|
+
from stapel_orders.models import OrderStatus, PaymentStatus
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"OrderStatusEnum": OrderStatus,
|
|
103
|
+
"PaymentStatusEnum": PaymentStatus,
|
|
104
|
+
}
|