smx-commerce 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.
- smx_commerce-0.1.0/MANIFEST.in +30 -0
- smx_commerce-0.1.0/PKG-INFO +13 -0
- smx_commerce-0.1.0/README.md +88 -0
- smx_commerce-0.1.0/pyproject.toml +33 -0
- smx_commerce-0.1.0/setup.cfg +4 -0
- smx_commerce-0.1.0/src/smx_commerce/__init__.py +214 -0
- smx_commerce-0.1.0/src/smx_commerce/admin/__init__.py +31 -0
- smx_commerce-0.1.0/src/smx_commerce/admin/amounts.py +51 -0
- smx_commerce-0.1.0/src/smx_commerce/admin/auth.py +115 -0
- smx_commerce-0.1.0/src/smx_commerce/admin/category_edit.py +78 -0
- smx_commerce-0.1.0/src/smx_commerce/admin/home.py +13 -0
- smx_commerce-0.1.0/src/smx_commerce/admin/order_edit.py +87 -0
- smx_commerce-0.1.0/src/smx_commerce/admin/price_edit.py +79 -0
- smx_commerce-0.1.0/src/smx_commerce/admin/product_edit.py +72 -0
- smx_commerce-0.1.0/src/smx_commerce/admin/routes.py +32 -0
- smx_commerce-0.1.0/src/smx_commerce/admin/safe_delete.py +263 -0
- smx_commerce-0.1.0/src/smx_commerce/catalog/__init__.py +29 -0
- smx_commerce-0.1.0/src/smx_commerce/catalog/models.py +120 -0
- smx_commerce-0.1.0/src/smx_commerce/catalog/objects.py +225 -0
- smx_commerce-0.1.0/src/smx_commerce/catalog/repository.py +506 -0
- smx_commerce-0.1.0/src/smx_commerce/catalog/routes_admin.py +484 -0
- smx_commerce-0.1.0/src/smx_commerce/catalog/routes_public.py +100 -0
- smx_commerce-0.1.0/src/smx_commerce/catalog/services.py +68 -0
- smx_commerce-0.1.0/src/smx_commerce/checkout/__init__.py +12 -0
- smx_commerce-0.1.0/src/smx_commerce/checkout/models.py +50 -0
- smx_commerce-0.1.0/src/smx_commerce/checkout/objects.py +80 -0
- smx_commerce-0.1.0/src/smx_commerce/checkout/repository.py +212 -0
- smx_commerce-0.1.0/src/smx_commerce/checkout/routes.py +181 -0
- smx_commerce-0.1.0/src/smx_commerce/checkout/routes_admin.py +178 -0
- smx_commerce-0.1.0/src/smx_commerce/checkout/services.py +45 -0
- smx_commerce-0.1.0/src/smx_commerce/cli.py +104 -0
- smx_commerce-0.1.0/src/smx_commerce/core/__init__.py +17 -0
- smx_commerce-0.1.0/src/smx_commerce/core/config.py +37 -0
- smx_commerce-0.1.0/src/smx_commerce/core/db.py +42 -0
- smx_commerce-0.1.0/src/smx_commerce/core/runtime.py +62 -0
- smx_commerce-0.1.0/src/smx_commerce/core/schema.py +39 -0
- smx_commerce-0.1.0/src/smx_commerce/core/settings_models.py +30 -0
- smx_commerce-0.1.0/src/smx_commerce/core/settings_repository.py +69 -0
- smx_commerce-0.1.0/src/smx_commerce/env_config.py +116 -0
- smx_commerce-0.1.0/src/smx_commerce/notifications/__init__.py +17 -0
- smx_commerce-0.1.0/src/smx_commerce/notifications/emailer.py +68 -0
- smx_commerce-0.1.0/src/smx_commerce/notifications/memory_sender.py +11 -0
- smx_commerce-0.1.0/src/smx_commerce/notifications/smtp_sender.py +55 -0
- smx_commerce-0.1.0/src/smx_commerce/payments/__init__.py +26 -0
- smx_commerce-0.1.0/src/smx_commerce/payments/checkout.py +29 -0
- smx_commerce-0.1.0/src/smx_commerce/payments/local.py +57 -0
- smx_commerce-0.1.0/src/smx_commerce/payments/models.py +36 -0
- smx_commerce-0.1.0/src/smx_commerce/payments/objects.py +37 -0
- smx_commerce-0.1.0/src/smx_commerce/payments/repository.py +144 -0
- smx_commerce-0.1.0/src/smx_commerce/payments/routes.py +114 -0
- smx_commerce-0.1.0/src/smx_commerce/payments/services.py +84 -0
- smx_commerce-0.1.0/src/smx_commerce/payments/stripe_checkout.py +99 -0
- smx_commerce-0.1.0/src/smx_commerce/payments/stripe_verifier.py +62 -0
- smx_commerce-0.1.0/src/smx_commerce/payments/verifiers.py +28 -0
- smx_commerce-0.1.0/src/smx_commerce/settings.py +51 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/admin/categories_list.html +135 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/admin/category_edit.html +80 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/admin/login.html +44 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/admin/order_edit.html +90 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/admin/orders_list.html +85 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/admin/price_edit.html +88 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/admin/product_detail.html +147 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/admin/product_edit.html +93 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/admin/products_list.html +150 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/public/checkout_cancel.html +35 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/public/checkout_success.html +41 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/public/commerce_home.html +35 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/public/product_detail.html +98 -0
- smx_commerce-0.1.0/src/smx_commerce/templates/public/product_list.html +44 -0
- smx_commerce-0.1.0/src/smx_commerce.egg-info/PKG-INFO +13 -0
- smx_commerce-0.1.0/src/smx_commerce.egg-info/SOURCES.txt +129 -0
- smx_commerce-0.1.0/src/smx_commerce.egg-info/dependency_links.txt +1 -0
- smx_commerce-0.1.0/src/smx_commerce.egg-info/entry_points.txt +2 -0
- smx_commerce-0.1.0/src/smx_commerce.egg-info/requires.txt +11 -0
- smx_commerce-0.1.0/src/smx_commerce.egg-info/top_level.txt +1 -0
- smx_commerce-0.1.0/tests/test_admin_auth.py +63 -0
- smx_commerce-0.1.0/tests/test_admin_categories_html.py +111 -0
- smx_commerce-0.1.0/tests/test_admin_category_edit_form.py +130 -0
- smx_commerce-0.1.0/tests/test_admin_home_route.py +76 -0
- smx_commerce-0.1.0/tests/test_admin_login_navigation.py +73 -0
- smx_commerce-0.1.0/tests/test_admin_order_edit_form.py +179 -0
- smx_commerce-0.1.0/tests/test_admin_order_safe_delete.py +183 -0
- smx_commerce-0.1.0/tests/test_admin_orders_html.py +125 -0
- smx_commerce-0.1.0/tests/test_admin_price_edit_form.py +135 -0
- smx_commerce-0.1.0/tests/test_admin_price_major_amount_forms.py +151 -0
- smx_commerce-0.1.0/tests/test_admin_product_categories_form.py +122 -0
- smx_commerce-0.1.0/tests/test_admin_product_create_form.py +101 -0
- smx_commerce-0.1.0/tests/test_admin_product_detail_price_form.py +129 -0
- smx_commerce-0.1.0/tests/test_admin_product_edit_form.py +126 -0
- smx_commerce-0.1.0/tests/test_admin_products_html.py +72 -0
- smx_commerce-0.1.0/tests/test_admin_safe_delete_feedback.py +164 -0
- smx_commerce-0.1.0/tests/test_admin_safe_delete_routes.py +191 -0
- smx_commerce-0.1.0/tests/test_admin_safe_delete_ui.py +197 -0
- smx_commerce-0.1.0/tests/test_admin_session_auth.py +94 -0
- smx_commerce-0.1.0/tests/test_catalog_objects.py +113 -0
- smx_commerce-0.1.0/tests/test_catalog_service.py +99 -0
- smx_commerce-0.1.0/tests/test_category_admin_routes.py +105 -0
- smx_commerce-0.1.0/tests/test_category_repository.py +119 -0
- smx_commerce-0.1.0/tests/test_checkout_redirect_html.py +109 -0
- smx_commerce-0.1.0/tests/test_checkout_redirect_routes.py +115 -0
- smx_commerce-0.1.0/tests/test_checkout_routes.py +183 -0
- smx_commerce-0.1.0/tests/test_checkout_service.py +104 -0
- smx_commerce-0.1.0/tests/test_checkout_session_provider.py +116 -0
- smx_commerce-0.1.0/tests/test_cli_schema_commands.py +47 -0
- smx_commerce-0.1.0/tests/test_commerce_runtime.py +70 -0
- smx_commerce-0.1.0/tests/test_commerce_settings_repository.py +49 -0
- smx_commerce-0.1.0/tests/test_init_commerce_api.py +88 -0
- smx_commerce-0.1.0/tests/test_init_commerce_config_modes.py +175 -0
- smx_commerce-0.1.0/tests/test_order_admin_routes.py +136 -0
- smx_commerce-0.1.0/tests/test_order_confirmation_email_service.py +66 -0
- smx_commerce-0.1.0/tests/test_order_repository.py +176 -0
- smx_commerce-0.1.0/tests/test_orders_csv_export.py +113 -0
- smx_commerce-0.1.0/tests/test_package_contract_workflow.py +202 -0
- smx_commerce-0.1.0/tests/test_payment_webhook_routes.py +196 -0
- smx_commerce-0.1.0/tests/test_payment_webhook_service.py +130 -0
- smx_commerce-0.1.0/tests/test_price_admin_routes.py +137 -0
- smx_commerce-0.1.0/tests/test_product_admin_routes.py +124 -0
- smx_commerce-0.1.0/tests/test_product_price_repository.py +180 -0
- smx_commerce-0.1.0/tests/test_product_repository.py +147 -0
- smx_commerce-0.1.0/tests/test_public_checkout_form.py +92 -0
- smx_commerce-0.1.0/tests/test_public_commerce_home_navigation.py +51 -0
- smx_commerce-0.1.0/tests/test_public_navigation_shell.py +108 -0
- smx_commerce-0.1.0/tests/test_public_product_html_routes.py +102 -0
- smx_commerce-0.1.0/tests/test_public_product_navigation.py +67 -0
- smx_commerce-0.1.0/tests/test_public_product_routes.py +130 -0
- smx_commerce-0.1.0/tests/test_schema_readiness.py +31 -0
- smx_commerce-0.1.0/tests/test_settings_admin_routes.py +59 -0
- smx_commerce-0.1.0/tests/test_smtp_email_sender.py +118 -0
- smx_commerce-0.1.0/tests/test_stripe_checkout_provider.py +106 -0
- smx_commerce-0.1.0/tests/test_stripe_webhook_verifier.py +15 -0
- smx_commerce-0.1.0/tests/test_webhook_order_confirmation_integration.py +195 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
include README.md
|
|
2
|
+
include LICENSE
|
|
3
|
+
include pyproject.toml
|
|
4
|
+
|
|
5
|
+
recursive-include src/smx_commerce/templates *.html
|
|
6
|
+
|
|
7
|
+
recursive-exclude * __pycache__
|
|
8
|
+
recursive-exclude * *.py[cod]
|
|
9
|
+
recursive-exclude * .DS_Store
|
|
10
|
+
recursive-exclude * Thumbs.db
|
|
11
|
+
|
|
12
|
+
exclude .env
|
|
13
|
+
exclude .env.*
|
|
14
|
+
exclude *.db
|
|
15
|
+
exclude *.sqlite
|
|
16
|
+
exclude *.sqlite3
|
|
17
|
+
|
|
18
|
+
exclude patch_*.py
|
|
19
|
+
exclude repair_*.py
|
|
20
|
+
exclude fix_*.py
|
|
21
|
+
exclude inspect_*.py
|
|
22
|
+
exclude _tmp_*.py
|
|
23
|
+
|
|
24
|
+
prune .venv
|
|
25
|
+
prune venv
|
|
26
|
+
prune env
|
|
27
|
+
prune build
|
|
28
|
+
prune dist
|
|
29
|
+
prune data
|
|
30
|
+
prune tests/__pycache__
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smx-commerce
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Independent commerce package for Flask and SyntaxMatrix-style applications.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: Flask>=2.3
|
|
7
|
+
Requires-Dist: SQLAlchemy>=2.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
10
|
+
Provides-Extra: stripe
|
|
11
|
+
Requires-Dist: stripe>=8.0; extra == "stripe"
|
|
12
|
+
Provides-Extra: postgres
|
|
13
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# smx-commerce
|
|
2
|
+
|
|
3
|
+
This is a minimal customer-facing demo app showing how a client project can install and initialize `smx-commerce`.
|
|
4
|
+
|
|
5
|
+
## Admin token and private admin entry point
|
|
6
|
+
|
|
7
|
+
Public visitors should not see an Admin button or Admin navigation on the commerce pages.
|
|
8
|
+
|
|
9
|
+
Admins enter the panel directly through:
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
/commerce/admin
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
If the project has an admin key configured, the system redirects the admin to:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
/commerce/admin/login
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The admin must then enter the configured admin key.
|
|
22
|
+
|
|
23
|
+
### Who creates the admin key?
|
|
24
|
+
|
|
25
|
+
The project owner creates the admin key. `smx-commerce` does not invent a hidden token.
|
|
26
|
+
|
|
27
|
+
For local development, copy:
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
.env.smx-commerce.example
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
to:
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
.env.smx-commerce
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then set:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
SMX_COMMERCE_ADMIN_API_KEY=<your-admin-token>
|
|
43
|
+
SMX_COMMERCE_FLASK_SECRET_KEY=<your-session-secret>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Generate strong values with:
|
|
47
|
+
|
|
48
|
+
```powershell
|
|
49
|
+
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use one generated value for `SMX_COMMERCE_ADMIN_API_KEY` and another generated value for `SMX_COMMERCE_FLASK_SECRET_KEY`.
|
|
53
|
+
|
|
54
|
+
### Local demo example
|
|
55
|
+
|
|
56
|
+
For this local demo, the admin page is:
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
http://127.0.0.1:5055/commerce/admin
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The local key is whatever you set in:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
.env.smx-commerce
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Cloud deployment
|
|
69
|
+
|
|
70
|
+
For cloud deployment, set the same values as environment variables or secrets in the hosting platform.
|
|
71
|
+
|
|
72
|
+
Example names:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
SMX_COMMERCE_ADMIN_API_KEY
|
|
76
|
+
SMX_COMMERCE_FLASK_SECRET_KEY
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Recommended practice:
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
- Store them in a secret manager.
|
|
83
|
+
- Do not hardcode them in source code.
|
|
84
|
+
- Do not commit them to Git.
|
|
85
|
+
- Rotate the admin key if it is exposed.
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
After changing secrets, restart the app or service so the new values are loaded.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "smx-commerce"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Independent commerce package for Flask and SyntaxMatrix-style applications."
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"Flask>=2.3",
|
|
12
|
+
"SQLAlchemy>=2.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8.0",
|
|
18
|
+
]
|
|
19
|
+
stripe = [
|
|
20
|
+
"stripe>=8.0",
|
|
21
|
+
]
|
|
22
|
+
postgres = [
|
|
23
|
+
"psycopg[binary]>=3.1",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
smx-commerce = "smx_commerce.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
smx_commerce = ["templates/**/*.html"]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from flask import Blueprint, render_template
|
|
4
|
+
|
|
5
|
+
from smx_commerce.admin import apply_admin_api_key_guard, create_admin_auth_blueprint, create_admin_home_blueprint, create_settings_admin_blueprint, create_product_edit_admin_blueprint, create_price_edit_admin_blueprint, create_category_edit_admin_blueprint, create_safe_delete_admin_blueprint, create_order_edit_admin_blueprint, create_product_edit_admin_blueprint
|
|
6
|
+
from smx_commerce.catalog.routes_admin import (
|
|
7
|
+
create_category_admin_blueprint,
|
|
8
|
+
create_price_admin_blueprint,
|
|
9
|
+
create_product_admin_blueprint,
|
|
10
|
+
)
|
|
11
|
+
from smx_commerce.catalog.routes_public import create_public_catalog_blueprint
|
|
12
|
+
from smx_commerce.checkout.routes import create_checkout_blueprint
|
|
13
|
+
from smx_commerce.checkout.routes_admin import create_order_admin_blueprint
|
|
14
|
+
from smx_commerce.core import CommerceRuntime
|
|
15
|
+
from smx_commerce.env_config import build_commerce_config_from_env, load_env_file
|
|
16
|
+
from smx_commerce.notifications import (
|
|
17
|
+
MemoryEmailSender,
|
|
18
|
+
OrderConfirmationEmailService,
|
|
19
|
+
SMTPEmailSender,
|
|
20
|
+
)
|
|
21
|
+
from smx_commerce.payments import (
|
|
22
|
+
LocalCheckoutProvider,
|
|
23
|
+
PaymentCheckoutProvider,
|
|
24
|
+
StaticSignatureWebhookVerifier,
|
|
25
|
+
StripeCheckoutProvider,
|
|
26
|
+
)
|
|
27
|
+
from smx_commerce.payments.routes import create_payment_webhook_blueprint
|
|
28
|
+
from smx_commerce.payments.verifiers import PaymentWebhookVerifier
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_commerce_blueprint(
|
|
32
|
+
config=None,
|
|
33
|
+
runtime: CommerceRuntime | None = None,
|
|
34
|
+
init_schema: bool = False,
|
|
35
|
+
payment_webhook_verifier: PaymentWebhookVerifier | None = None,
|
|
36
|
+
payment_checkout_provider: PaymentCheckoutProvider | None = None,
|
|
37
|
+
order_confirmation_service: OrderConfirmationEmailService | None = None,
|
|
38
|
+
admin_api_key: str | None = None,
|
|
39
|
+
):
|
|
40
|
+
commerce_runtime = runtime or CommerceRuntime.from_mapping(config)
|
|
41
|
+
|
|
42
|
+
if init_schema:
|
|
43
|
+
commerce_runtime.init_schema()
|
|
44
|
+
|
|
45
|
+
resolved_admin_api_key = admin_api_key
|
|
46
|
+
if resolved_admin_api_key is None:
|
|
47
|
+
resolved_admin_api_key = commerce_runtime.config.admin_api_key
|
|
48
|
+
|
|
49
|
+
bp = Blueprint("smx_commerce", __name__)
|
|
50
|
+
|
|
51
|
+
@bp.get("/commerce/health")
|
|
52
|
+
def commerce_health():
|
|
53
|
+
return {
|
|
54
|
+
"status": "ok",
|
|
55
|
+
"package": "smx-commerce",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@bp.get("/commerce")
|
|
59
|
+
def commerce_home():
|
|
60
|
+
return render_template("public/commerce_home.html")
|
|
61
|
+
|
|
62
|
+
bp.register_blueprint(create_public_catalog_blueprint(commerce_runtime))
|
|
63
|
+
bp.register_blueprint(
|
|
64
|
+
create_checkout_blueprint(
|
|
65
|
+
commerce_runtime,
|
|
66
|
+
payment_checkout_provider=payment_checkout_provider,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
bp.register_blueprint(
|
|
70
|
+
create_payment_webhook_blueprint(
|
|
71
|
+
commerce_runtime,
|
|
72
|
+
verifier=payment_webhook_verifier,
|
|
73
|
+
order_confirmation_service=order_confirmation_service,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
admin_bp = Blueprint("smx_commerce_admin", __name__)
|
|
78
|
+
apply_admin_api_key_guard(admin_bp, resolved_admin_api_key)
|
|
79
|
+
|
|
80
|
+
admin_bp.register_blueprint(create_admin_auth_blueprint(resolved_admin_api_key))
|
|
81
|
+
admin_bp.register_blueprint(create_admin_home_blueprint())
|
|
82
|
+
admin_bp.register_blueprint(create_category_admin_blueprint(commerce_runtime))
|
|
83
|
+
admin_bp.register_blueprint(create_category_edit_admin_blueprint(commerce_runtime))
|
|
84
|
+
admin_bp.register_blueprint(create_safe_delete_admin_blueprint(commerce_runtime))
|
|
85
|
+
admin_bp.register_blueprint(create_product_admin_blueprint(commerce_runtime))
|
|
86
|
+
admin_bp.register_blueprint(create_product_edit_admin_blueprint(commerce_runtime))
|
|
87
|
+
admin_bp.register_blueprint(create_price_admin_blueprint(commerce_runtime))
|
|
88
|
+
admin_bp.register_blueprint(create_price_edit_admin_blueprint(commerce_runtime))
|
|
89
|
+
admin_bp.register_blueprint(create_order_admin_blueprint(commerce_runtime))
|
|
90
|
+
admin_bp.register_blueprint(create_order_edit_admin_blueprint(commerce_runtime))
|
|
91
|
+
admin_bp.register_blueprint(create_settings_admin_blueprint(commerce_runtime))
|
|
92
|
+
|
|
93
|
+
bp.register_blueprint(admin_bp, url_prefix="/commerce/admin")
|
|
94
|
+
|
|
95
|
+
return bp
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def init_commerce(app, *, config=None, init_schema: bool = False):
|
|
99
|
+
resolved_config = config or {}
|
|
100
|
+
|
|
101
|
+
if resolved_config.get("flask_secret_key") and not app.secret_key:
|
|
102
|
+
app.config["SECRET_KEY"] = resolved_config["flask_secret_key"]
|
|
103
|
+
|
|
104
|
+
app.register_blueprint(
|
|
105
|
+
create_commerce_blueprint(
|
|
106
|
+
config=resolved_config,
|
|
107
|
+
init_schema=init_schema,
|
|
108
|
+
payment_checkout_provider=_build_checkout_provider(resolved_config),
|
|
109
|
+
payment_webhook_verifier=_build_webhook_verifier(resolved_config),
|
|
110
|
+
order_confirmation_service=_build_order_confirmation_service(resolved_config),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return app
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def init_commerce_from_env(
|
|
118
|
+
app,
|
|
119
|
+
*,
|
|
120
|
+
env_file: str = ".env.smx-commerce",
|
|
121
|
+
init_schema: bool = False,
|
|
122
|
+
prefix: str = "SMX_COMMERCE_",
|
|
123
|
+
):
|
|
124
|
+
config = build_commerce_config_from_env(
|
|
125
|
+
env_file=env_file,
|
|
126
|
+
prefix=prefix,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return init_commerce(
|
|
130
|
+
app,
|
|
131
|
+
config=config,
|
|
132
|
+
init_schema=init_schema,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _build_checkout_provider(config: dict):
|
|
137
|
+
provider = config.get("payment_provider")
|
|
138
|
+
|
|
139
|
+
if provider in {None, "", "none"}:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
if provider == "local":
|
|
143
|
+
return LocalCheckoutProvider(
|
|
144
|
+
checkout_base_url=config.get(
|
|
145
|
+
"local_checkout_base_url",
|
|
146
|
+
"https://local-payments.invalid/checkout",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if provider == "stripe":
|
|
151
|
+
return StripeCheckoutProvider(
|
|
152
|
+
api_key=config["stripe_secret_key"],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
raise ValueError(f"unsupported payment_provider: {provider}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_webhook_verifier(config: dict):
|
|
159
|
+
provider = config.get("payment_provider")
|
|
160
|
+
|
|
161
|
+
if provider in {None, "", "none"}:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
if provider == "local":
|
|
165
|
+
return StaticSignatureWebhookVerifier(
|
|
166
|
+
expected_signature=config.get("local_webhook_signature", "local-signature"),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if provider == "stripe":
|
|
170
|
+
return __import__(
|
|
171
|
+
"smx_commerce.payments",
|
|
172
|
+
fromlist=["StripeWebhookVerifier"],
|
|
173
|
+
).StripeWebhookVerifier(
|
|
174
|
+
webhook_secret=config["stripe_webhook_secret"],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
raise ValueError(f"unsupported payment_provider: {provider}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _build_order_confirmation_service(config: dict):
|
|
181
|
+
email_provider = config.get("email_provider")
|
|
182
|
+
|
|
183
|
+
if email_provider in {None, "", "none"}:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
if email_provider == "memory":
|
|
187
|
+
sender = MemoryEmailSender()
|
|
188
|
+
elif email_provider == "smtp":
|
|
189
|
+
sender = SMTPEmailSender(
|
|
190
|
+
host=config["smtp_host"],
|
|
191
|
+
port=int(config.get("smtp_port", 587)),
|
|
192
|
+
default_from_email=config.get("default_from_email"),
|
|
193
|
+
username=config.get("smtp_username"),
|
|
194
|
+
password=config.get("smtp_password"),
|
|
195
|
+
use_tls=bool(config.get("smtp_use_tls", True)),
|
|
196
|
+
use_ssl=bool(config.get("smtp_use_ssl", False)),
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
raise ValueError(f"unsupported email_provider: {email_provider}")
|
|
200
|
+
|
|
201
|
+
return OrderConfirmationEmailService(
|
|
202
|
+
sender,
|
|
203
|
+
from_email=config.get("default_from_email"),
|
|
204
|
+
brand_name=config.get("brand_name", "smx-commerce"),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
__all__ = [
|
|
209
|
+
"build_commerce_config_from_env",
|
|
210
|
+
"create_commerce_blueprint",
|
|
211
|
+
"init_commerce",
|
|
212
|
+
"init_commerce_from_env",
|
|
213
|
+
"load_env_file",
|
|
214
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from .home import create_admin_home_blueprint
|
|
2
|
+
from .auth import (
|
|
3
|
+
ADMIN_KEY_HEADER,
|
|
4
|
+
ADMIN_SESSION_KEY,
|
|
5
|
+
apply_admin_api_key_guard,
|
|
6
|
+
create_admin_auth_blueprint,
|
|
7
|
+
)
|
|
8
|
+
from .routes import create_settings_admin_blueprint
|
|
9
|
+
from .product_edit import create_product_edit_admin_blueprint
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"create_order_edit_admin_blueprint",
|
|
13
|
+
"create_safe_delete_admin_blueprint",
|
|
14
|
+
"create_category_edit_admin_blueprint",
|
|
15
|
+
"create_price_edit_admin_blueprint",
|
|
16
|
+
"ADMIN_KEY_HEADER",
|
|
17
|
+
"ADMIN_SESSION_KEY",
|
|
18
|
+
"apply_admin_api_key_guard",
|
|
19
|
+
"create_admin_auth_blueprint",
|
|
20
|
+
"create_admin_home_blueprint",
|
|
21
|
+
"create_settings_admin_blueprint",
|
|
22
|
+
"create_product_edit_admin_blueprint",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
from .price_edit import create_price_edit_admin_blueprint
|
|
26
|
+
|
|
27
|
+
from .category_edit import create_category_edit_admin_blueprint
|
|
28
|
+
|
|
29
|
+
from .safe_delete import create_safe_delete_admin_blueprint
|
|
30
|
+
|
|
31
|
+
from .order_edit import create_order_edit_admin_blueprint
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_admin_amount_to_cents(value: str, *, field_name: str = "amount") -> int:
|
|
7
|
+
raw_value = str(value or "").strip()
|
|
8
|
+
|
|
9
|
+
if not raw_value:
|
|
10
|
+
raise ValueError(f"{field_name} is required")
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
amount = Decimal(raw_value)
|
|
14
|
+
except InvalidOperation as exc:
|
|
15
|
+
raise ValueError(f"{field_name} must be a valid currency amount") from exc
|
|
16
|
+
|
|
17
|
+
if amount < 0:
|
|
18
|
+
raise ValueError(f"{field_name} cannot be negative")
|
|
19
|
+
|
|
20
|
+
cents = (amount * Decimal("100")).quantize(
|
|
21
|
+
Decimal("1"),
|
|
22
|
+
rounding=ROUND_HALF_UP,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
return int(cents)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_admin_price_amount_from_payload(payload: dict, *, is_form: bool) -> int:
|
|
29
|
+
"""
|
|
30
|
+
Browser forms should send amount_major, for example:
|
|
31
|
+
1 -> 100
|
|
32
|
+
1.50 -> 150
|
|
33
|
+
299 -> 29900
|
|
34
|
+
|
|
35
|
+
JSON/API callers keep using amount_cents.
|
|
36
|
+
|
|
37
|
+
For temporary backwards compatibility, form submissions that still send
|
|
38
|
+
amount_cents are also accepted.
|
|
39
|
+
"""
|
|
40
|
+
if is_form:
|
|
41
|
+
amount_major = payload.get("amount_major")
|
|
42
|
+
|
|
43
|
+
if amount_major not in {None, ""}:
|
|
44
|
+
return parse_admin_amount_to_cents(amount_major, field_name="amount")
|
|
45
|
+
|
|
46
|
+
if payload.get("amount_cents") not in {None, ""}:
|
|
47
|
+
return int(payload.get("amount_cents", 0) or 0)
|
|
48
|
+
|
|
49
|
+
return parse_admin_amount_to_cents("", field_name="amount")
|
|
50
|
+
|
|
51
|
+
return int(payload.get("amount_cents", 0) or 0)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hmac import compare_digest
|
|
4
|
+
|
|
5
|
+
from flask import Blueprint, current_app, jsonify, redirect, render_template, request, session, url_for
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
ADMIN_KEY_HEADER = "X-SMX-Commerce-Admin-Key"
|
|
9
|
+
ADMIN_SESSION_KEY = "smx_commerce_admin_authenticated"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def apply_admin_api_key_guard(bp: Blueprint, admin_api_key: str | None) -> None:
|
|
13
|
+
"""
|
|
14
|
+
Protect admin routes when admin_api_key is configured.
|
|
15
|
+
|
|
16
|
+
Accepted auth methods:
|
|
17
|
+
1. X-SMX-Commerce-Admin-Key header, useful for API clients/tests.
|
|
18
|
+
2. Session login through /commerce/admin/login, useful for browser admin UX.
|
|
19
|
+
|
|
20
|
+
If no key is configured, the guard is inactive.
|
|
21
|
+
"""
|
|
22
|
+
if not admin_api_key:
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
expected_key = admin_api_key.strip()
|
|
26
|
+
|
|
27
|
+
@bp.before_request
|
|
28
|
+
def require_admin_auth():
|
|
29
|
+
if _is_auth_route():
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
supplied_key = request.headers.get(ADMIN_KEY_HEADER, "")
|
|
33
|
+
|
|
34
|
+
if supplied_key and compare_digest(supplied_key, expected_key):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
if session.get(ADMIN_SESSION_KEY) is True:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
if _wants_html():
|
|
41
|
+
return redirect(url_for("smx_commerce.smx_commerce_admin.smx_commerce_admin_auth.login"))
|
|
42
|
+
|
|
43
|
+
return jsonify({"error": "admin authentication required"}), 401
|
|
44
|
+
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_admin_auth_blueprint(admin_api_key: str | None) -> Blueprint:
|
|
49
|
+
bp = Blueprint(
|
|
50
|
+
"smx_commerce_admin_auth",
|
|
51
|
+
__name__,
|
|
52
|
+
template_folder="../templates",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@bp.get("/login")
|
|
56
|
+
def login():
|
|
57
|
+
if not admin_api_key:
|
|
58
|
+
return jsonify({"status": "ok", "message": "admin authentication is not configured"})
|
|
59
|
+
|
|
60
|
+
if request.accept_mimetypes.best_match(["text/html", "application/json"]) == "text/html":
|
|
61
|
+
return render_template("admin/login.html")
|
|
62
|
+
|
|
63
|
+
return jsonify({"status": "ok", "message": "admin login is available"})
|
|
64
|
+
|
|
65
|
+
@bp.post("/login")
|
|
66
|
+
def submit_login():
|
|
67
|
+
if not admin_api_key:
|
|
68
|
+
return jsonify({"status": "ok", "message": "admin authentication is not configured"})
|
|
69
|
+
|
|
70
|
+
if not current_app.secret_key:
|
|
71
|
+
return jsonify({"error": "Flask secret_key is required for admin session login"}), 500
|
|
72
|
+
|
|
73
|
+
payload = request.get_json(silent=True) or {}
|
|
74
|
+
supplied_key = request.form.get("admin_api_key") or payload.get("admin_api_key") or ""
|
|
75
|
+
|
|
76
|
+
if not compare_digest(supplied_key, admin_api_key):
|
|
77
|
+
if _wants_html():
|
|
78
|
+
return render_template("admin/login.html", error="Invalid admin key"), 401
|
|
79
|
+
|
|
80
|
+
return jsonify({"error": "invalid admin key"}), 401
|
|
81
|
+
|
|
82
|
+
session[ADMIN_SESSION_KEY] = True
|
|
83
|
+
|
|
84
|
+
if _wants_html():
|
|
85
|
+
return redirect("/commerce/admin/products", code=303)
|
|
86
|
+
|
|
87
|
+
return jsonify({"status": "ok", "authenticated": True})
|
|
88
|
+
|
|
89
|
+
@bp.post("/logout")
|
|
90
|
+
def logout():
|
|
91
|
+
session.pop(ADMIN_SESSION_KEY, None)
|
|
92
|
+
|
|
93
|
+
if _wants_html():
|
|
94
|
+
return redirect("/commerce/admin/login", code=303)
|
|
95
|
+
|
|
96
|
+
return jsonify({"status": "ok", "authenticated": False})
|
|
97
|
+
|
|
98
|
+
return bp
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_auth_route() -> bool:
|
|
102
|
+
path = request.path.rstrip("/")
|
|
103
|
+
return path.endswith("/commerce/admin/login") or path.endswith("/commerce/admin/logout")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _wants_html() -> bool:
|
|
107
|
+
content_type = request.content_type or ""
|
|
108
|
+
|
|
109
|
+
if "application/x-www-form-urlencoded" in content_type:
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
if "multipart/form-data" in content_type:
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
return request.accept_mimetypes.best_match(["text/html", "application/json"]) == "text/html"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from flask import Blueprint, jsonify, redirect, render_template, request
|
|
4
|
+
|
|
5
|
+
from smx_commerce.catalog import CatalogService
|
|
6
|
+
from smx_commerce.core import CommerceRuntime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_category_edit_admin_blueprint(runtime: CommerceRuntime) -> Blueprint:
|
|
10
|
+
bp = Blueprint("smx_commerce_category_edit_admin", __name__)
|
|
11
|
+
|
|
12
|
+
@bp.get("/categories/<slug>/edit")
|
|
13
|
+
def edit_category(slug: str):
|
|
14
|
+
with runtime.session_scope() as session:
|
|
15
|
+
catalog = CatalogService(session)
|
|
16
|
+
category = catalog.get_category(slug)
|
|
17
|
+
categories = catalog.list_categories(include_archived=False)
|
|
18
|
+
|
|
19
|
+
if category is None:
|
|
20
|
+
return jsonify({"error": f"category not found: {slug}"}), 404
|
|
21
|
+
|
|
22
|
+
parent_options = [
|
|
23
|
+
item for item in categories
|
|
24
|
+
if item.slug != category.slug
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
return render_template(
|
|
28
|
+
"admin/category_edit.html",
|
|
29
|
+
category=category,
|
|
30
|
+
parent_options=parent_options,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@bp.post("/categories/<slug>/update")
|
|
34
|
+
def update_category_from_form(slug: str):
|
|
35
|
+
payload = request.form
|
|
36
|
+
|
|
37
|
+
parent_slug = payload.get("parent_slug") or None
|
|
38
|
+
|
|
39
|
+
if parent_slug == slug:
|
|
40
|
+
parent_slug = None
|
|
41
|
+
|
|
42
|
+
changes = {
|
|
43
|
+
"name": payload.get("name", ""),
|
|
44
|
+
"description": payload.get("description", ""),
|
|
45
|
+
"status": payload.get("status", "active"),
|
|
46
|
+
"parent_slug": parent_slug,
|
|
47
|
+
"sort_order": int(payload.get("sort_order", 0) or 0),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
with runtime.session_scope() as session:
|
|
52
|
+
catalog = CatalogService(session)
|
|
53
|
+
updated = catalog.update_category(slug, **changes)
|
|
54
|
+
|
|
55
|
+
return redirect("/commerce/admin/categories", code=303)
|
|
56
|
+
|
|
57
|
+
except (TypeError, ValueError) as exc:
|
|
58
|
+
with runtime.session_scope() as session:
|
|
59
|
+
catalog = CatalogService(session)
|
|
60
|
+
category = catalog.get_category(slug)
|
|
61
|
+
categories = catalog.list_categories(include_archived=False)
|
|
62
|
+
|
|
63
|
+
if category is None:
|
|
64
|
+
return jsonify({"error": f"category not found: {slug}"}), 404
|
|
65
|
+
|
|
66
|
+
parent_options = [
|
|
67
|
+
item for item in categories
|
|
68
|
+
if item.slug != category.slug
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
return render_template(
|
|
72
|
+
"admin/category_edit.html",
|
|
73
|
+
category=category,
|
|
74
|
+
parent_options=parent_options,
|
|
75
|
+
error=str(exc),
|
|
76
|
+
), 400
|
|
77
|
+
|
|
78
|
+
return bp
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from flask import Blueprint, redirect
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_admin_home_blueprint() -> Blueprint:
|
|
7
|
+
bp = Blueprint("smx_commerce_admin_home", __name__)
|
|
8
|
+
|
|
9
|
+
@bp.get("/", strict_slashes=False)
|
|
10
|
+
def admin_home():
|
|
11
|
+
return redirect("/commerce/admin/products", code=303)
|
|
12
|
+
|
|
13
|
+
return bp
|