stapel-orders 0.1.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.
- stapel_orders/CONFIG.MD +27 -0
- stapel_orders/__init__.py +31 -0
- stapel_orders/_capabilities.py +36 -0
- stapel_orders/_codegen.py +120 -0
- stapel_orders/_codegen_settings.py +104 -0
- stapel_orders/actions.py +69 -0
- stapel_orders/admin.py +87 -0
- stapel_orders/apps.py +23 -0
- stapel_orders/beat.py +47 -0
- stapel_orders/checks.py +101 -0
- stapel_orders/codegen_urls.py +6 -0
- stapel_orders/conf.py +58 -0
- stapel_orders/conftest.py +117 -0
- stapel_orders/docs/capabilities.json +165 -0
- stapel_orders/docs/errors.json +467 -0
- stapel_orders/docs/flows.json +215 -0
- stapel_orders/docs/llms.txt +140 -0
- stapel_orders/dto.py +99 -0
- stapel_orders/errors.py +59 -0
- stapel_orders/flows.py +58 -0
- stapel_orders/fsm.py +67 -0
- stapel_orders/functions.py +84 -0
- stapel_orders/gdpr.py +127 -0
- stapel_orders/management/__init__.py +0 -0
- stapel_orders/management/commands/__init__.py +0 -0
- stapel_orders/management/commands/purge_carts.py +14 -0
- stapel_orders/migrations/0001_initial.py +113 -0
- stapel_orders/migrations/__init__.py +0 -0
- stapel_orders/models.py +320 -0
- stapel_orders/money.py +119 -0
- stapel_orders/payment.py +120 -0
- stapel_orders/presenters.py +137 -0
- stapel_orders/py.typed +0 -0
- stapel_orders/registry.py +191 -0
- stapel_orders/schemas/consumes/payment.completed.json +15 -0
- stapel_orders/schemas/consumes/user.merged.json +12 -0
- stapel_orders/schemas/emits/orders.order.paid.json +12 -0
- stapel_orders/schemas/emits/orders.order.placed.json +31 -0
- stapel_orders/schemas/emits/orders.order.status_changed.json +16 -0
- stapel_orders/schemas/functions/orders.cart.count.json +11 -0
- stapel_orders/schemas/functions/orders.order.status.json +11 -0
- stapel_orders/schemas/functions/orders.orders_export.json +11 -0
- stapel_orders/schemas/functions/purchasables.json +37 -0
- stapel_orders/serializers.py +159 -0
- stapel_orders/services/__init__.py +20 -0
- stapel_orders/services/cart.py +274 -0
- stapel_orders/services/checkout.py +290 -0
- stapel_orders/services/events.py +119 -0
- stapel_orders/services/orders.py +153 -0
- stapel_orders/urls.py +14 -0
- stapel_orders/urls_v1.py +45 -0
- stapel_orders/views.py +284 -0
- stapel_orders-0.1.0.dist-info/METADATA +157 -0
- stapel_orders-0.1.0.dist-info/RECORD +57 -0
- stapel_orders-0.1.0.dist-info/WHEEL +5 -0
- stapel_orders-0.1.0.dist-info/licenses/LICENSE +21 -0
- stapel_orders-0.1.0.dist-info/top_level.txt +1 -0
stapel_orders/CONFIG.MD
ADDED
|
@@ -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,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
|
+
}
|
stapel_orders/actions.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Action subscriptions of stapel-orders.
|
|
2
|
+
|
|
3
|
+
Handlers are idempotent: delivery is at-least-once.
|
|
4
|
+
|
|
5
|
+
- ``user.merged`` (stapel-auth) — a guest was folded into an account on
|
|
6
|
+
sign-in: its cart lines join the survivor's cart (same pair summed and
|
|
7
|
+
capped, another currency dropped) and its orders follow it.
|
|
8
|
+
- ``payment.completed`` (a payment provider; billing ask B1, or the mock) —
|
|
9
|
+
only ``reference_type == "order"`` is ours.
|
|
10
|
+
- ``user.deleted`` / ``gdpr.erasure.requested`` are answered by
|
|
11
|
+
``register_gdpr_owner`` (``apps.py``), not here.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from django.db import transaction
|
|
18
|
+
from stapel_core.comm import on_action
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_CONSUMES = Path(__file__).resolve().parent / "schemas" / "consumes"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _schema(name: str) -> dict:
|
|
26
|
+
return json.loads((_CONSUMES / f"{name}.json").read_text(encoding="utf-8"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@on_action("user.merged", schema=_schema("user.merged"))
|
|
30
|
+
def handle_user_merged(event):
|
|
31
|
+
"""Re-parent the guest's cart lines and orders to the surviving account."""
|
|
32
|
+
from .models import CheckoutAttempt, Order
|
|
33
|
+
from .services.cart import merge_carts
|
|
34
|
+
|
|
35
|
+
payload = event.payload or {}
|
|
36
|
+
from_user_id = str(payload.get("from_user_id") or "")
|
|
37
|
+
into_user_id = str(payload.get("into_user_id") or "")
|
|
38
|
+
if not from_user_id or not into_user_id:
|
|
39
|
+
logger.error("user.merged without from/into user id: %s", event.event_id)
|
|
40
|
+
return
|
|
41
|
+
if from_user_id == into_user_id:
|
|
42
|
+
return
|
|
43
|
+
with transaction.atomic():
|
|
44
|
+
cart = merge_carts(from_user_id, into_user_id)
|
|
45
|
+
orders = Order.objects.filter(user_id=from_user_id).update(user_id=into_user_id)
|
|
46
|
+
survivor_keys = set(
|
|
47
|
+
CheckoutAttempt.objects.filter(user_id=into_user_id).values_list(
|
|
48
|
+
"idempotency_key", flat=True
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
CheckoutAttempt.objects.filter(
|
|
52
|
+
user_id=from_user_id, idempotency_key__in=survivor_keys
|
|
53
|
+
).delete()
|
|
54
|
+
CheckoutAttempt.objects.filter(user_id=from_user_id).update(user_id=into_user_id)
|
|
55
|
+
if cart["moved"] or cart["summed"] or cart["dropped"] or orders:
|
|
56
|
+
logger.info(
|
|
57
|
+
"user.merged %s -> %s: cart moved %s, summed %s, dropped %s; %s orders",
|
|
58
|
+
from_user_id, into_user_id, cart["moved"], cart["summed"], cart["dropped"], orders,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@on_action("payment.completed", schema=_schema("payment.completed"))
|
|
63
|
+
def handle_payment_completed(event):
|
|
64
|
+
"""Mark an order paid, or send it to review when the amount disagrees."""
|
|
65
|
+
from .services.orders import apply_payment
|
|
66
|
+
|
|
67
|
+
outcome = apply_payment(event.payload or {})
|
|
68
|
+
if outcome not in ("ignored", "duplicate"):
|
|
69
|
+
logger.info("payment.completed %s: %s", event.event_id, outcome)
|
stapel_orders/admin.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Admin for stapel-orders.
|
|
2
|
+
|
|
3
|
+
Money and snapshots are read-only; status moves only through the service
|
|
4
|
+
(transition actions), never a raw field edit, so every move is checked by
|
|
5
|
+
the FSM and announced on the bus.
|
|
6
|
+
"""
|
|
7
|
+
from django.contrib import admin, messages
|
|
8
|
+
|
|
9
|
+
from .models import Cart, CartLine, Order, OrderLine, OrderStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OrderLineInline(admin.TabularInline):
|
|
13
|
+
model = OrderLine
|
|
14
|
+
extra = 0
|
|
15
|
+
can_delete = False
|
|
16
|
+
fields = ("purchasable_type", "purchasable_key", "title", "quantity",
|
|
17
|
+
"unit_price", "line_total", "seller_id")
|
|
18
|
+
readonly_fields = fields
|
|
19
|
+
|
|
20
|
+
def has_add_permission(self, request, obj=None):
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _transition_action(to_status: str):
|
|
25
|
+
def action(modeladmin, request, queryset):
|
|
26
|
+
from .services import OrdersError
|
|
27
|
+
from .services.orders import staff_transition
|
|
28
|
+
|
|
29
|
+
moved = refused = 0
|
|
30
|
+
for order in queryset:
|
|
31
|
+
try:
|
|
32
|
+
staff_transition(order.pk, to_status, reason="admin")
|
|
33
|
+
moved += 1
|
|
34
|
+
except OrdersError:
|
|
35
|
+
refused += 1
|
|
36
|
+
if moved:
|
|
37
|
+
modeladmin.message_user(request, f"{moved} order(s) moved to {to_status}.")
|
|
38
|
+
if refused:
|
|
39
|
+
modeladmin.message_user(
|
|
40
|
+
request, f"{refused} order(s) cannot move to {to_status}.", messages.WARNING
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
action.__name__ = f"mark_{to_status}"
|
|
44
|
+
action.short_description = f"Mark selected orders {to_status}"
|
|
45
|
+
return action
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@admin.register(Order)
|
|
49
|
+
class OrderAdmin(admin.ModelAdmin):
|
|
50
|
+
list_display = ("number", "status", "payment_status", "total", "currency", "created_at")
|
|
51
|
+
list_filter = ("status", "payment_status", "currency")
|
|
52
|
+
search_fields = ("number", "user_id")
|
|
53
|
+
inlines = [OrderLineInline]
|
|
54
|
+
readonly_fields = (
|
|
55
|
+
"number", "user_id", "status", "payment_status", "currency", "subtotal",
|
|
56
|
+
"total", "total_base", "base_currency", "provider_ref", "contact_name",
|
|
57
|
+
"contact_phone", "contact_email", "address_line1", "address_line2",
|
|
58
|
+
"address_city", "address_region", "address_postal_code", "address_country",
|
|
59
|
+
"shipping_method", "comment", "created_at", "updated_at",
|
|
60
|
+
)
|
|
61
|
+
actions = [
|
|
62
|
+
_transition_action(OrderStatus.CONFIRMED),
|
|
63
|
+
_transition_action(OrderStatus.SHIPPED),
|
|
64
|
+
_transition_action(OrderStatus.COMPLETED),
|
|
65
|
+
_transition_action(OrderStatus.CANCELLED),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
def has_add_permission(self, request):
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CartLineInline(admin.TabularInline):
|
|
73
|
+
model = CartLine
|
|
74
|
+
extra = 0
|
|
75
|
+
fields = ("purchasable_type", "purchasable_key", "title", "quantity", "unit_price", "line_total")
|
|
76
|
+
readonly_fields = fields
|
|
77
|
+
|
|
78
|
+
def has_add_permission(self, request, obj=None):
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@admin.register(Cart)
|
|
83
|
+
class CartAdmin(admin.ModelAdmin):
|
|
84
|
+
list_display = ("user_id", "currency", "updated_at")
|
|
85
|
+
search_fields = ("user_id",)
|
|
86
|
+
readonly_fields = ("user_id", "currency", "created_at", "updated_at")
|
|
87
|
+
inlines = [CartLineInline]
|
stapel_orders/apps.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class OrdersConfig(AppConfig):
|
|
5
|
+
name = "stapel_orders"
|
|
6
|
+
label = "orders"
|
|
7
|
+
verbose_name = "Cart, checkout and orders"
|
|
8
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
from . import actions # noqa: F401
|
|
12
|
+
from . import checks # noqa: F401
|
|
13
|
+
from . import errors # noqa: F401
|
|
14
|
+
from . import flows # noqa: F401
|
|
15
|
+
from . import functions # noqa: F401
|
|
16
|
+
|
|
17
|
+
from stapel_core.gdpr import gdpr_registry, register_gdpr_owner
|
|
18
|
+
|
|
19
|
+
from .gdpr import OWNER, SUBJECT_TYPES, OrdersGDPRProvider, erase_subject
|
|
20
|
+
|
|
21
|
+
if not any(p.section == OWNER for p in gdpr_registry.providers):
|
|
22
|
+
gdpr_registry.register(OrdersGDPRProvider())
|
|
23
|
+
register_gdpr_owner(OWNER, SUBJECT_TYPES, erase_subject)
|
stapel_orders/beat.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""The schedule that purges abandoned carts — shipped by the library.
|
|
2
|
+
|
|
3
|
+
Wire it in the settings module itself::
|
|
4
|
+
|
|
5
|
+
from stapel_orders.beat import get_orders_beat_schedule
|
|
6
|
+
|
|
7
|
+
CELERY_BEAT_SCHEDULE = {**get_orders_beat_schedule(), ...}
|
|
8
|
+
|
|
9
|
+
Imports settings and nothing else, so it is safe from a settings module.
|
|
10
|
+
Celery is optional: ``manage.py purge_carts`` runs the same pass from any
|
|
11
|
+
scheduler.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
PURGE_TASK_NAME = "stapel_orders.beat.purge_carts"
|
|
16
|
+
PURGE_BEAT_KEY = "orders-purge-carts"
|
|
17
|
+
#: Daily. CART_TTL_DAYS is measured in days, so a finer cadence buys nothing.
|
|
18
|
+
PURGE_INTERVAL_SECONDS = 24 * 60 * 60
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def purge_carts() -> int:
|
|
22
|
+
"""One purge pass. A plain callable for hosts without Celery."""
|
|
23
|
+
from .services.cart import purge_carts as _purge
|
|
24
|
+
|
|
25
|
+
return _purge()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_orders_beat_schedule(*, seconds: int = PURGE_INTERVAL_SECONDS) -> dict:
|
|
29
|
+
"""Beat entry for the cart purge. Add to ``CELERY_BEAT_SCHEDULE``."""
|
|
30
|
+
return {PURGE_BEAT_KEY: {"task": PURGE_TASK_NAME, "schedule": float(seconds)}}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
try: # pragma: no cover - exercised by whichever profile the host installs
|
|
34
|
+
from celery import shared_task
|
|
35
|
+
except ImportError:
|
|
36
|
+
pass
|
|
37
|
+
else:
|
|
38
|
+
purge_carts = shared_task(name=PURGE_TASK_NAME)(purge_carts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"PURGE_BEAT_KEY",
|
|
43
|
+
"PURGE_INTERVAL_SECONDS",
|
|
44
|
+
"PURGE_TASK_NAME",
|
|
45
|
+
"get_orders_beat_schedule",
|
|
46
|
+
"purge_carts",
|
|
47
|
+
]
|
stapel_orders/checks.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Django system checks for stapel-orders.
|
|
2
|
+
|
|
3
|
+
- E001 ``PURCHASABLE_TYPES`` is empty: a shop with nothing to sell.
|
|
4
|
+
- E003 a ``PURCHASABLE_TYPES`` entry is not ``{"resolve": "<function>"}``.
|
|
5
|
+
- W001 a registered resolver is not reachable on this transport.
|
|
6
|
+
- E002 / W002 ``PAYMENT="mock"`` outside / inside the prototype posture stage.
|
|
7
|
+
- E004 ``PAYMENT`` names no known provider.
|
|
8
|
+
- E005 the money bounds no longer fit the columns (total could overflow).
|
|
9
|
+
"""
|
|
10
|
+
from django.core import checks
|
|
11
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@checks.register(checks.Tags.compatibility)
|
|
15
|
+
def check_purchasable_types(app_configs, **kwargs):
|
|
16
|
+
from .conf import orders_settings
|
|
17
|
+
from .registry import get_purchasable_types
|
|
18
|
+
|
|
19
|
+
raw = orders_settings.PURCHASABLE_TYPES
|
|
20
|
+
if raw is not None and not isinstance(raw, dict):
|
|
21
|
+
return [checks.Error(
|
|
22
|
+
"STAPEL_ORDERS['PURCHASABLE_TYPES'] must be a dict of "
|
|
23
|
+
"{type_name: {'resolve': '<comm function>'} | None}.",
|
|
24
|
+
id="stapel_orders.E003",
|
|
25
|
+
)]
|
|
26
|
+
types = get_purchasable_types()
|
|
27
|
+
if not types:
|
|
28
|
+
return [checks.Error(
|
|
29
|
+
"STAPEL_ORDERS['PURCHASABLE_TYPES'] is empty: the cart cannot "
|
|
30
|
+
"accept anything.",
|
|
31
|
+
hint="Register a type, e.g. {'listing': {'resolve': "
|
|
32
|
+
"'listings.purchasables'}} (the stapel-shop preset does this), "
|
|
33
|
+
"or register_purchasable_type() from your AppConfig.ready().",
|
|
34
|
+
id="stapel_orders.E001",
|
|
35
|
+
)]
|
|
36
|
+
findings = []
|
|
37
|
+
from stapel_core.comm import function_unreachable_reason
|
|
38
|
+
|
|
39
|
+
for name, entry in sorted(types.items()):
|
|
40
|
+
if not isinstance(entry, dict) or not entry.get("resolve"):
|
|
41
|
+
findings.append(checks.Error(
|
|
42
|
+
f"STAPEL_ORDERS['PURCHASABLE_TYPES'][{name!r}] must be "
|
|
43
|
+
"{'resolve': '<comm function name>'}.",
|
|
44
|
+
id="stapel_orders.E003",
|
|
45
|
+
))
|
|
46
|
+
continue
|
|
47
|
+
reason = function_unreachable_reason(str(entry["resolve"]))
|
|
48
|
+
if reason:
|
|
49
|
+
findings.append(checks.Warning(
|
|
50
|
+
f"Purchasable type {name!r}: resolver {entry['resolve']!r} is "
|
|
51
|
+
f"not reachable here ({reason}). Adds and checkouts of this "
|
|
52
|
+
"type will answer 503.",
|
|
53
|
+
id="stapel_orders.W001",
|
|
54
|
+
))
|
|
55
|
+
return findings
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@checks.register(checks.Tags.compatibility)
|
|
59
|
+
def check_payment(app_configs, **kwargs):
|
|
60
|
+
from stapel_core.django.presets import stage_finding
|
|
61
|
+
|
|
62
|
+
from .conf import orders_settings
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
provider = orders_settings.PAYMENT
|
|
66
|
+
except ImproperlyConfigured as exc:
|
|
67
|
+
return [checks.Error(str(exc), id="stapel_orders.E004")]
|
|
68
|
+
if getattr(provider, "name", "") != "mock":
|
|
69
|
+
return []
|
|
70
|
+
finding = checks.Error(
|
|
71
|
+
"STAPEL_ORDERS['PAYMENT'] is 'mock': every order is marked paid "
|
|
72
|
+
"without any money moving.",
|
|
73
|
+
hint="Use PAYMENT='none' (staff confirm by hand) or a real provider; "
|
|
74
|
+
"the mock is for a prototype posture only.",
|
|
75
|
+
id="stapel_orders.E002",
|
|
76
|
+
)
|
|
77
|
+
return [stage_finding(finding, warning_id="stapel_orders.W002")]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@checks.register(checks.Tags.compatibility)
|
|
81
|
+
def check_money_bounds(app_configs, **kwargs):
|
|
82
|
+
from . import money
|
|
83
|
+
from .services.cart import limits
|
|
84
|
+
|
|
85
|
+
max_quantity, max_lines = limits()
|
|
86
|
+
if max_quantity < 1 or max_lines < 1:
|
|
87
|
+
return [checks.Error(
|
|
88
|
+
"STAPEL_ORDERS['MAX_LINE_QUANTITY'] and ['MAX_LINES_PER_CART'] "
|
|
89
|
+
"must be positive.",
|
|
90
|
+
id="stapel_orders.E005",
|
|
91
|
+
)]
|
|
92
|
+
if not money.columns_hold(max_quantity, max_lines):
|
|
93
|
+
return [checks.Error(
|
|
94
|
+
f"MAX_LINE_QUANTITY={max_quantity} x MAX_LINES_PER_CART={max_lines} "
|
|
95
|
+
f"x unit price < 10^{money.UNIT_PRICE_INTEGER_DIGITS} can exceed the "
|
|
96
|
+
f"line ({money.LINE_TOTAL_MAX_DIGITS} digits) or order "
|
|
97
|
+
f"({money.ORDER_TOTAL_MAX_DIGITS} digits) total column.",
|
|
98
|
+
hint="Lower one of the two limits; the columns are the proof's ceiling.",
|
|
99
|
+
id="stapel_orders.E005",
|
|
100
|
+
)]
|
|
101
|
+
return []
|