Split cart into 4 microservices: relations, likes, orders, page-config→blog
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Phase 1 - Relations service (internal): owns ContainerRelation, exposes get-children data + attach/detach-child actions. Retargeted events, blog, market callers from cart to relations. Phase 2 - Likes service (internal): unified Like model replaces ProductLike and PostLike with generic target_type/target_slug/target_id. Exposes is-liked, liked-slugs, liked-ids data + toggle action. Phase 3 - PageConfig → blog: moved ownership to blog with direct DB queries, removed proxy endpoints from cart. Phase 4 - Orders service (public): owns Order/OrderItem + SumUp checkout flow. Cart checkout now delegates to orders via create-order action. Webhook/return routes and reconciliation moved to orders. Phase 5 - Infrastructure: docker-compose, deploy.sh, Dockerfiles updated for all 3 new services. Added orders_url helper and factory model imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
56
orders/Dockerfile
Normal file
56
orders/Dockerfile
Normal file
@@ -0,0 +1,56 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------- Python application ----------
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
APP_PORT=8000 \
|
||||
APP_MODULE=app:app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system deps + psql client
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY shared/requirements.txt ./requirements.txt
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# Shared code (replaces submodule)
|
||||
COPY shared/ ./shared/
|
||||
|
||||
# App code
|
||||
COPY orders/ ./
|
||||
|
||||
# Sibling models for cross-domain SQLAlchemy imports
|
||||
COPY blog/__init__.py ./blog/__init__.py
|
||||
COPY blog/models/ ./blog/models/
|
||||
COPY market/__init__.py ./market/__init__.py
|
||||
COPY market/models/ ./market/models/
|
||||
COPY cart/__init__.py ./cart/__init__.py
|
||||
COPY cart/models/ ./cart/models/
|
||||
COPY events/__init__.py ./events/__init__.py
|
||||
COPY events/models/ ./events/models/
|
||||
COPY federation/__init__.py ./federation/__init__.py
|
||||
COPY federation/models/ ./federation/models/
|
||||
COPY account/__init__.py ./account/__init__.py
|
||||
COPY account/models/ ./account/models/
|
||||
COPY relations/__init__.py ./relations/__init__.py
|
||||
COPY relations/models/ ./relations/models/
|
||||
COPY likes/__init__.py ./likes/__init__.py
|
||||
COPY likes/models/ ./likes/models/
|
||||
|
||||
# ---------- Runtime setup ----------
|
||||
COPY orders/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE ${APP_PORT}
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
0
orders/__init__.py
Normal file
0
orders/__init__.py
Normal file
35
orders/alembic.ini
Normal file
35
orders/alembic.ini
Normal file
@@ -0,0 +1,35 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
12
orders/alembic/env.py
Normal file
12
orders/alembic/env.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from alembic import context
|
||||
from shared.db.alembic_env import run_alembic
|
||||
|
||||
MODELS = [
|
||||
"shared.models.order",
|
||||
]
|
||||
|
||||
TABLES = frozenset({
|
||||
"orders", "order_items",
|
||||
})
|
||||
|
||||
run_alembic(context.config, MODELS, TABLES)
|
||||
67
orders/alembic/versions/0001_initial.py
Normal file
67
orders/alembic/versions/0001_initial.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Initial orders tables
|
||||
|
||||
Revision ID: orders_0001
|
||||
Revises: None
|
||||
Create Date: 2026-02-27
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "orders_0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, name):
|
||||
result = conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=:t"
|
||||
), {"t": name})
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _table_exists(op.get_bind(), "orders"):
|
||||
op.create_table(
|
||||
"orders",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer, nullable=True),
|
||||
sa.Column("session_id", sa.String(64), nullable=True),
|
||||
sa.Column("page_config_id", sa.Integer, nullable=True),
|
||||
sa.Column("status", sa.String(32), nullable=False, server_default="pending"),
|
||||
sa.Column("currency", sa.String(16), nullable=False, server_default="GBP"),
|
||||
sa.Column("total_amount", sa.Numeric(12, 2), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("sumup_reference", sa.String(255), nullable=True),
|
||||
sa.Column("sumup_checkout_id", sa.String(128), nullable=True),
|
||||
sa.Column("sumup_status", sa.String(32), nullable=True),
|
||||
sa.Column("sumup_hosted_url", sa.Text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_orders_session_id", "orders", ["session_id"])
|
||||
op.create_index("ix_orders_page_config_id", "orders", ["page_config_id"])
|
||||
op.create_index("ix_orders_description", "orders", ["description"], postgresql_using="hash")
|
||||
op.create_index("ix_orders_sumup_reference", "orders", ["sumup_reference"])
|
||||
op.create_index("ix_orders_sumup_checkout_id", "orders", ["sumup_checkout_id"])
|
||||
|
||||
if not _table_exists(op.get_bind(), "order_items"):
|
||||
op.create_table(
|
||||
"order_items",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("order_id", sa.Integer, sa.ForeignKey("orders.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("product_id", sa.Integer, nullable=False),
|
||||
sa.Column("product_title", sa.String(512), nullable=True),
|
||||
sa.Column("product_slug", sa.String(512), nullable=True),
|
||||
sa.Column("product_image", sa.Text, nullable=True),
|
||||
sa.Column("quantity", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("unit_price", sa.Numeric(12, 2), nullable=False),
|
||||
sa.Column("currency", sa.String(16), nullable=False, server_default="GBP"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("order_items")
|
||||
op.drop_table("orders")
|
||||
129
orders/app.py
Normal file
129
orders/app.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from quart import g, abort, request
|
||||
from jinja2 import FileSystemLoader, ChoiceLoader
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
|
||||
from bp import (
|
||||
register_orders,
|
||||
register_order,
|
||||
register_checkout,
|
||||
register_fragments,
|
||||
register_actions,
|
||||
register_data,
|
||||
)
|
||||
|
||||
|
||||
async def orders_context() -> dict:
|
||||
"""Orders app context processor."""
|
||||
from shared.infrastructure.context import base_context
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
|
||||
ctx = await base_context()
|
||||
ctx["menu_items"] = []
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
ident = current_cart_identity()
|
||||
cart_params = {}
|
||||
if ident["user_id"] is not None:
|
||||
cart_params["user_id"] = ident["user_id"]
|
||||
if ident["session_id"] is not None:
|
||||
cart_params["session_id"] = ident["session_id"]
|
||||
|
||||
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
|
||||
("cart", "cart-mini", cart_params or None),
|
||||
("account", "auth-menu", {"email": user.email} if user else None),
|
||||
("blog", "nav-tree", {"app_name": "orders", "path": request.path}),
|
||||
])
|
||||
ctx["cart_mini_html"] = cart_mini_html
|
||||
ctx["auth_menu_html"] = auth_menu_html
|
||||
ctx["nav_tree_html"] = nav_tree_html
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def _make_page_config(raw: dict) -> SimpleNamespace:
|
||||
"""Convert a page-config JSON dict to a namespace for SumUp helpers."""
|
||||
return SimpleNamespace(**raw)
|
||||
|
||||
|
||||
def create_app() -> "Quart":
|
||||
from services import register_domain_services
|
||||
|
||||
app = create_base_app(
|
||||
"orders",
|
||||
context_fn=orders_context,
|
||||
domain_services_fn=register_domain_services,
|
||||
)
|
||||
|
||||
# App-specific templates override shared templates
|
||||
app_templates = str(Path(__file__).resolve().parent / "templates")
|
||||
app.jinja_loader = ChoiceLoader([
|
||||
FileSystemLoader(app_templates),
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
app.register_blueprint(register_actions())
|
||||
app.register_blueprint(register_data())
|
||||
|
||||
# Orders list at /
|
||||
app.register_blueprint(register_orders(url_prefix="/"))
|
||||
|
||||
# Checkout webhook + return
|
||||
app.register_blueprint(register_checkout())
|
||||
|
||||
# --- Reconcile stale pending orders on startup ---
|
||||
@app.before_serving
|
||||
async def _reconcile_pending_orders():
|
||||
"""Check SumUp status for orders stuck in 'pending' with a checkout ID."""
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import select as sel
|
||||
from shared.db.session import get_session
|
||||
from shared.models.order import Order
|
||||
from services.check_sumup_status import check_sumup_status
|
||||
|
||||
log = logging.getLogger("orders.reconcile")
|
||||
|
||||
try:
|
||||
async with get_session() as sess:
|
||||
async with sess.begin():
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=2)
|
||||
result = await sess.execute(
|
||||
sel(Order)
|
||||
.where(
|
||||
Order.status == "pending",
|
||||
Order.sumup_checkout_id.isnot(None),
|
||||
Order.created_at < cutoff,
|
||||
)
|
||||
.limit(50)
|
||||
)
|
||||
stale_orders = result.scalars().all()
|
||||
|
||||
if not stale_orders:
|
||||
return
|
||||
|
||||
log.info("Reconciling %d stale pending orders", len(stale_orders))
|
||||
for order in stale_orders:
|
||||
try:
|
||||
await check_sumup_status(sess, order)
|
||||
log.info(
|
||||
"Order %d reconciled: %s",
|
||||
order.id, order.status,
|
||||
)
|
||||
except Exception:
|
||||
log.exception("Failed to reconcile order %d", order.id)
|
||||
except Exception:
|
||||
log.exception("Order reconciliation failed")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
6
orders/bp/__init__.py
Normal file
6
orders/bp/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .order.routes import register as register_order
|
||||
from .orders.routes import register as register_orders
|
||||
from .checkout.routes import register as register_checkout
|
||||
from .data.routes import register as register_data
|
||||
from .actions.routes import register as register_actions
|
||||
from .fragments.routes import register as register_fragments
|
||||
0
orders/bp/actions/__init__.py
Normal file
0
orders/bp/actions/__init__.py
Normal file
108
orders/bp/actions/routes.py
Normal file
108
orders/bp/actions/routes.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Orders app action endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
try:
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Action %s failed", action_name)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
# --- create-order ---
|
||||
async def _create_order():
|
||||
"""Create an order from cart data. Called by cart during checkout."""
|
||||
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
||||
from shared.infrastructure.urls import orders_url
|
||||
from services.checkout import (
|
||||
create_order,
|
||||
resolve_page_config_from_post_id,
|
||||
build_sumup_description,
|
||||
build_sumup_reference,
|
||||
build_webhook_url,
|
||||
)
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
|
||||
cart_items = data.get("cart_items", [])
|
||||
calendar_entries = data.get("calendar_entries", [])
|
||||
tickets = data.get("tickets", [])
|
||||
user_id = data.get("user_id")
|
||||
session_id = data.get("session_id")
|
||||
product_total = data.get("product_total", 0)
|
||||
calendar_total = data.get("calendar_total", 0)
|
||||
ticket_total = data.get("ticket_total", 0)
|
||||
page_post_id = data.get("page_post_id")
|
||||
|
||||
order = await create_order(
|
||||
g.s, cart_items, calendar_entries,
|
||||
user_id, session_id,
|
||||
product_total, calendar_total,
|
||||
ticket_total=ticket_total,
|
||||
page_post_id=page_post_id,
|
||||
)
|
||||
|
||||
page_config = None
|
||||
if page_post_id:
|
||||
page_config = await resolve_page_config_from_post_id(page_post_id)
|
||||
|
||||
if page_config:
|
||||
order.page_config_id = page_config.id
|
||||
|
||||
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
|
||||
description = build_sumup_description(cart_items, order.id, ticket_count=len(tickets))
|
||||
|
||||
# Build URLs using orders service's own domain
|
||||
redirect_url = orders_url(f"/checkout/return/{order.id}/")
|
||||
webhook_base_url = orders_url(f"/checkout/webhook/{order.id}/")
|
||||
webhook_url = build_webhook_url(webhook_base_url)
|
||||
|
||||
checkout_data = await sumup_create_checkout(
|
||||
order,
|
||||
redirect_url=redirect_url,
|
||||
webhook_url=webhook_url,
|
||||
description=description,
|
||||
page_config=page_config,
|
||||
)
|
||||
order.sumup_checkout_id = checkout_data.get("id")
|
||||
order.sumup_status = checkout_data.get("status")
|
||||
order.description = checkout_data.get("description")
|
||||
hosted_cfg = checkout_data.get("hosted_checkout") or {}
|
||||
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
|
||||
order.sumup_hosted_url = hosted_url
|
||||
await g.s.flush()
|
||||
|
||||
return {
|
||||
"order_id": order.id,
|
||||
"sumup_hosted_url": hosted_url,
|
||||
"page_config_id": order.page_config_id,
|
||||
"sumup_reference": order.sumup_reference,
|
||||
"description": order.description,
|
||||
}
|
||||
|
||||
_handlers["create-order"] = _create_order
|
||||
|
||||
return bp
|
||||
0
orders/bp/checkout/__init__.py
Normal file
0
orders/bp/checkout/__init__.py
Normal file
99
orders/bp/checkout/routes.py
Normal file
99
orders/bp/checkout/routes.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Checkout webhook + return routes (moved from cart/bp/cart/global_routes.py)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, request, render_template, make_response
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.models.order import Order
|
||||
from shared.browser.app.csrf import csrf_exempt
|
||||
|
||||
from services.checkout import validate_webhook_secret, get_order_with_details
|
||||
from services.check_sumup_status import check_sumup_status
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("checkout", __name__, url_prefix="/checkout")
|
||||
|
||||
@csrf_exempt
|
||||
@bp.post("/webhook/<int:order_id>/")
|
||||
async def checkout_webhook(order_id: int):
|
||||
"""Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events."""
|
||||
if not validate_webhook_secret(request.args.get("token")):
|
||||
return "", 204
|
||||
try:
|
||||
payload = await request.get_json()
|
||||
except Exception:
|
||||
payload = None
|
||||
if not isinstance(payload, dict):
|
||||
return "", 204
|
||||
if payload.get("event_type") != "CHECKOUT_STATUS_CHANGED":
|
||||
return "", 204
|
||||
checkout_id = payload.get("id")
|
||||
if not checkout_id:
|
||||
return "", 204
|
||||
result = await g.s.execute(select(Order).where(Order.id == order_id))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
return "", 204
|
||||
if order.sumup_checkout_id and order.sumup_checkout_id != checkout_id:
|
||||
return "", 204
|
||||
try:
|
||||
await check_sumup_status(g.s, order)
|
||||
except Exception:
|
||||
pass
|
||||
return "", 204
|
||||
|
||||
@bp.get("/return/<int:order_id>/")
|
||||
async def checkout_return(order_id: int):
|
||||
"""Handle the browser returning from SumUp after payment."""
|
||||
order = await get_order_with_details(g.s, order_id)
|
||||
if not order:
|
||||
html = await render_template(
|
||||
"_types/cart/checkout_return.html",
|
||||
order=None, status="missing", calendar_entries=[],
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
if order.page_config_id:
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict
|
||||
raw_pc = await fetch_data("blog", "page-config-by-id",
|
||||
params={"id": order.page_config_id}, required=False)
|
||||
post = await fetch_data("blog", "post-by-id",
|
||||
params={"id": raw_pc["container_id"]}, required=False) if raw_pc else None
|
||||
if post:
|
||||
g.page_slug = post["slug"]
|
||||
mps = await fetch_data(
|
||||
"market", "marketplaces-for-container",
|
||||
params={"type": "page", "id": post["id"]}, required=False,
|
||||
) or []
|
||||
if mps:
|
||||
g.market_slug = mps[0].get("slug")
|
||||
|
||||
if order.sumup_checkout_id:
|
||||
try:
|
||||
await check_sumup_status(g.s, order)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
status = (order.status or "pending").lower()
|
||||
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict
|
||||
raw_entries = await fetch_data("events", "entries-for-order",
|
||||
params={"order_id": order.id}, required=False) or []
|
||||
calendar_entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw_entries]
|
||||
raw_tickets = await fetch_data("events", "tickets-for-order",
|
||||
params={"order_id": order.id}, required=False) or []
|
||||
order_tickets = [dto_from_dict(TicketDTO, t) for t in raw_tickets]
|
||||
await g.s.flush()
|
||||
|
||||
html = await render_template(
|
||||
"_types/cart/checkout_return.html",
|
||||
order=order, status=status,
|
||||
calendar_entries=calendar_entries,
|
||||
order_tickets=order_tickets,
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
0
orders/bp/data/__init__.py
Normal file
0
orders/bp/data/__init__.py
Normal file
30
orders/bp/data/routes.py
Normal file
30
orders/bp/data/routes.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Orders app data endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
return bp
|
||||
0
orders/bp/fragments/__init__.py
Normal file
0
orders/bp/fragments/__init__.py
Normal file
42
orders/bp/fragments/routes.py
Normal file
42
orders/bp/fragments/routes.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Orders app fragment endpoints.
|
||||
|
||||
Fragments:
|
||||
account-nav-item "orders" link for account dashboard
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
async def _account_nav_item():
|
||||
from shared.infrastructure.urls import orders_url
|
||||
href = orders_url("/")
|
||||
return (
|
||||
'<div class="relative nav-group">'
|
||||
f'<a href="{href}" class="justify-center cursor-pointer flex flex-row '
|
||||
'items-center gap-2 rounded bg-stone-200 text-black p-3" data-hx-disable>'
|
||||
'orders</a></div>'
|
||||
)
|
||||
|
||||
_handlers = {
|
||||
"account-nav-item": _account_nav_item,
|
||||
}
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler = _handlers.get(fragment_type)
|
||||
if handler is None:
|
||||
return Response("", status=200, content_type="text/html")
|
||||
html = await handler()
|
||||
return Response(html, status=200, content_type="text/html")
|
||||
|
||||
return bp
|
||||
0
orders/bp/order/__init__.py
Normal file
0
orders/bp/order/__init__.py
Normal file
0
orders/bp/order/filters/__init__.py
Normal file
0
orders/bp/order/filters/__init__.py
Normal file
74
orders/bp/order/filters/qs.py
Normal file
74
orders/bp/order/filters/qs.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# suma_browser/app/bp/order/filters/qs.py
|
||||
from quart import request
|
||||
|
||||
from typing import Iterable, Optional, Union
|
||||
|
||||
from shared.browser.app.filters.qs_base import KEEP, build_qs
|
||||
from shared.browser.app.filters.query_types import OrderQuery
|
||||
|
||||
|
||||
def decode() -> OrderQuery:
|
||||
"""
|
||||
Decode current query string into an OrderQuery(page, search).
|
||||
"""
|
||||
try:
|
||||
page = int(request.args.get("page", 1) or 1)
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
search = request.args.get("search") or None
|
||||
return OrderQuery(page, search)
|
||||
|
||||
|
||||
def makeqs_factory():
|
||||
"""
|
||||
Build a makeqs(...) that starts from the current filters + page.
|
||||
|
||||
Behaviour:
|
||||
- If filters change and you don't explicitly pass page,
|
||||
the page is reset to 1 (same pattern as browse/blog).
|
||||
- You can clear search with search=None.
|
||||
"""
|
||||
q = decode()
|
||||
base_search = q.search or None
|
||||
base_page = int(q.page or 1)
|
||||
|
||||
def makeqs(
|
||||
*,
|
||||
clear_filters: bool = False,
|
||||
search: Union[str, None, object] = KEEP,
|
||||
page: Union[int, None, object] = None,
|
||||
extra: Optional[Iterable[tuple]] = None,
|
||||
leading_q: bool = True,
|
||||
) -> str:
|
||||
filters_changed = False
|
||||
|
||||
# --- search logic ---
|
||||
if search is KEEP and not clear_filters:
|
||||
final_search = base_search
|
||||
else:
|
||||
filters_changed = True
|
||||
final_search = (search or None)
|
||||
|
||||
# --- page logic ---
|
||||
if page is None:
|
||||
final_page = 1 if filters_changed else base_page
|
||||
else:
|
||||
final_page = page
|
||||
|
||||
# --- build params ---
|
||||
params: list[tuple[str, str]] = []
|
||||
|
||||
if final_search:
|
||||
params.append(("search", final_search))
|
||||
if final_page is not None:
|
||||
params.append(("page", str(final_page)))
|
||||
|
||||
if extra:
|
||||
for k, v in extra:
|
||||
if v is not None:
|
||||
params.append((k, str(v)))
|
||||
|
||||
return build_qs(params, leading_q=leading_q)
|
||||
|
||||
return makeqs
|
||||
124
orders/bp/order/routes.py
Normal file
124
orders/bp/order/routes.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, render_template, redirect, url_for, make_response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from shared.models.order import Order
|
||||
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
||||
from shared.config import config
|
||||
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from services.check_sumup_status import check_sumup_status
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
from .filters.qs import makeqs_factory, decode
|
||||
|
||||
|
||||
def _owner_filter():
|
||||
"""Return SQLAlchemy clause restricting orders to current user/session."""
|
||||
ident = current_cart_identity()
|
||||
if ident["user_id"]:
|
||||
return Order.user_id == ident["user_id"]
|
||||
if ident["session_id"]:
|
||||
return Order.session_id == ident["session_id"]
|
||||
return None
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("order", __name__, url_prefix='/<int:order_id>')
|
||||
|
||||
@bp.before_request
|
||||
def route():
|
||||
g.makeqs_factory = makeqs_factory
|
||||
|
||||
@bp.get("/")
|
||||
async def order_detail(order_id: int):
|
||||
"""Show a single order + items."""
|
||||
owner = _owner_filter()
|
||||
if owner is None:
|
||||
return await make_response("Order not found", 404)
|
||||
result = await g.s.execute(
|
||||
select(Order)
|
||||
.options(selectinload(Order.items))
|
||||
.where(Order.id == order_id, owner)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
return await make_response("Order not found", 404)
|
||||
if not is_htmx_request():
|
||||
html = await render_template("_types/order/index.html", order=order)
|
||||
else:
|
||||
html = await render_template("_types/order/_oob_elements.html", order=order)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/pay/")
|
||||
async def order_pay(order_id: int):
|
||||
"""Re-open the SumUp payment page for this order."""
|
||||
owner = _owner_filter()
|
||||
if owner is None:
|
||||
return await make_response("Order not found", 404)
|
||||
result = await g.s.execute(select(Order).where(Order.id == order_id, owner))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
return await make_response("Order not found", 404)
|
||||
|
||||
if order.status == "paid":
|
||||
return redirect(url_for("orders.order.order_detail", order_id=order.id))
|
||||
|
||||
if order.sumup_hosted_url:
|
||||
return redirect(order.sumup_hosted_url)
|
||||
|
||||
redirect_url = url_for("checkout.checkout_return", order_id=order.id, _external=True)
|
||||
sumup_cfg = config().get("sumup", {}) or {}
|
||||
webhook_secret = sumup_cfg.get("webhook_secret")
|
||||
webhook_url = url_for("checkout.checkout_webhook", order_id=order.id, _external=True)
|
||||
if webhook_secret:
|
||||
from urllib.parse import urlencode
|
||||
sep = "&" if "?" in webhook_url else "?"
|
||||
webhook_url = f"{webhook_url}{sep}{urlencode({'token': webhook_secret})}"
|
||||
|
||||
checkout_data = await sumup_create_checkout(
|
||||
order,
|
||||
redirect_url=redirect_url,
|
||||
webhook_url=webhook_url,
|
||||
)
|
||||
order.sumup_checkout_id = checkout_data.get("id")
|
||||
order.sumup_status = checkout_data.get("status")
|
||||
hosted_cfg = checkout_data.get("hosted_checkout") or {}
|
||||
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
|
||||
order.sumup_hosted_url = hosted_url
|
||||
await g.s.flush()
|
||||
|
||||
if not hosted_url:
|
||||
html = await render_template(
|
||||
"_types/cart/checkout_error.html",
|
||||
order=order,
|
||||
error="No hosted checkout URL returned from SumUp when trying to reopen payment.",
|
||||
)
|
||||
return await make_response(html, 500)
|
||||
|
||||
return redirect(hosted_url)
|
||||
|
||||
@bp.post("/recheck/")
|
||||
async def order_recheck(order_id: int):
|
||||
"""Manually re-check this order's status with SumUp."""
|
||||
owner = _owner_filter()
|
||||
if owner is None:
|
||||
return await make_response("Order not found", 404)
|
||||
result = await g.s.execute(select(Order).where(Order.id == order_id, owner))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
return await make_response("Order not found", 404)
|
||||
|
||||
if not order.sumup_checkout_id:
|
||||
return redirect(url_for("orders.order.order_detail", order_id=order.id))
|
||||
|
||||
try:
|
||||
await check_sumup_status(g.s, order)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return redirect(url_for("orders.order.order_detail", order_id=order.id))
|
||||
|
||||
return bp
|
||||
0
orders/bp/orders/__init__.py
Normal file
0
orders/bp/orders/__init__.py
Normal file
0
orders/bp/orders/filters/__init__.py
Normal file
0
orders/bp/orders/filters/__init__.py
Normal file
77
orders/bp/orders/filters/qs.py
Normal file
77
orders/bp/orders/filters/qs.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# suma_browser/app/bp/orders/filters/qs.py
|
||||
from quart import request
|
||||
|
||||
from typing import Iterable, Optional, Union
|
||||
|
||||
from shared.browser.app.filters.qs_base import KEEP, build_qs
|
||||
from shared.browser.app.filters.query_types import OrderQuery
|
||||
|
||||
|
||||
def decode() -> OrderQuery:
|
||||
"""
|
||||
Decode current query string into an OrderQuery(page, search).
|
||||
"""
|
||||
try:
|
||||
page = int(request.args.get("page", 1) or 1)
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
search = request.args.get("search") or None
|
||||
return OrderQuery(page, search)
|
||||
|
||||
|
||||
def makeqs_factory():
|
||||
"""
|
||||
Build a makeqs(...) that starts from the current filters + page.
|
||||
|
||||
Behaviour:
|
||||
- If filters change and you don't explicitly pass page,
|
||||
the page is reset to 1 (same pattern as browse/blog).
|
||||
- You can clear search with search=None.
|
||||
"""
|
||||
q = decode()
|
||||
base_search = q.search or None
|
||||
base_page = int(q.page or 1)
|
||||
|
||||
def makeqs(
|
||||
*,
|
||||
clear_filters: bool = False,
|
||||
search: Union[str, None, object] = KEEP,
|
||||
page: Union[int, None, object] = None,
|
||||
extra: Optional[Iterable[tuple]] = None,
|
||||
leading_q: bool = True,
|
||||
) -> str:
|
||||
filters_changed = False
|
||||
|
||||
# --- search logic ---
|
||||
if search is KEEP and not clear_filters:
|
||||
final_search = base_search
|
||||
else:
|
||||
filters_changed = True
|
||||
if search is KEEP:
|
||||
final_search = None
|
||||
else:
|
||||
final_search = (search or None)
|
||||
|
||||
# --- page logic ---
|
||||
if page is None:
|
||||
final_page = 1 if filters_changed else base_page
|
||||
else:
|
||||
final_page = page
|
||||
|
||||
# --- build params ---
|
||||
params: list[tuple[str, str]] = []
|
||||
|
||||
if final_search:
|
||||
params.append(("search", final_search))
|
||||
if final_page is not None:
|
||||
params.append(("page", str(final_page)))
|
||||
|
||||
if extra:
|
||||
for k, v in extra:
|
||||
if v is not None:
|
||||
params.append((k, str(v)))
|
||||
|
||||
return build_qs(params, leading_q=leading_q)
|
||||
|
||||
return makeqs
|
||||
138
orders/bp/orders/routes.py
Normal file
138
orders/bp/orders/routes.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, render_template, redirect, url_for, make_response
|
||||
from sqlalchemy import select, func, or_, cast, String, exists
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from shared.models.order import Order, OrderItem
|
||||
|
||||
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from bp.order.routes import register as register_order
|
||||
|
||||
from .filters.qs import makeqs_factory, decode
|
||||
|
||||
|
||||
def register(url_prefix: str) -> Blueprint:
|
||||
bp = Blueprint("orders", __name__, url_prefix=url_prefix)
|
||||
bp.register_blueprint(register_order())
|
||||
|
||||
ORDERS_PER_PAGE = 10
|
||||
|
||||
oob = {
|
||||
"extends": "_types/root/_index.html",
|
||||
"child_id": "auth-header-child",
|
||||
"header": "_types/auth/header/_header.html",
|
||||
"nav": "_types/auth/_nav.html",
|
||||
"main": "_types/auth/_main_panel.html",
|
||||
}
|
||||
|
||||
@bp.context_processor
|
||||
def inject_oob():
|
||||
return {"oob": oob}
|
||||
|
||||
@bp.before_request
|
||||
def route():
|
||||
g.makeqs_factory = makeqs_factory
|
||||
|
||||
@bp.before_request
|
||||
async def _require_identity():
|
||||
"""Orders require a logged-in user or at least a cart session."""
|
||||
ident = current_cart_identity()
|
||||
if not ident["user_id"] and not ident["session_id"]:
|
||||
return redirect(url_for("auth.login_form"))
|
||||
|
||||
@bp.get("/")
|
||||
async def list_orders():
|
||||
ident = current_cart_identity()
|
||||
if ident["user_id"]:
|
||||
owner_clause = Order.user_id == ident["user_id"]
|
||||
elif ident["session_id"]:
|
||||
owner_clause = Order.session_id == ident["session_id"]
|
||||
else:
|
||||
return redirect(url_for("auth.login_form"))
|
||||
|
||||
q = decode()
|
||||
page, search = q.page, q.search
|
||||
if page < 1:
|
||||
page = 1
|
||||
|
||||
where_clause = None
|
||||
if search:
|
||||
term = f"%{search.strip()}%"
|
||||
conditions = [
|
||||
Order.status.ilike(term),
|
||||
Order.currency.ilike(term),
|
||||
Order.sumup_checkout_id.ilike(term),
|
||||
Order.sumup_status.ilike(term),
|
||||
Order.description.ilike(term),
|
||||
]
|
||||
conditions.append(
|
||||
exists(
|
||||
select(1)
|
||||
.select_from(OrderItem)
|
||||
.where(
|
||||
OrderItem.order_id == Order.id,
|
||||
or_(
|
||||
OrderItem.product_title.ilike(term),
|
||||
OrderItem.product_slug.ilike(term),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
try:
|
||||
search_id = int(search)
|
||||
except (TypeError, ValueError):
|
||||
search_id = None
|
||||
if search_id is not None:
|
||||
conditions.append(Order.id == search_id)
|
||||
else:
|
||||
conditions.append(cast(Order.id, String).ilike(term))
|
||||
where_clause = or_(*conditions)
|
||||
|
||||
count_stmt = select(func.count()).select_from(Order).where(owner_clause)
|
||||
if where_clause is not None:
|
||||
count_stmt = count_stmt.where(where_clause)
|
||||
|
||||
total_count_result = await g.s.execute(count_stmt)
|
||||
total_count = total_count_result.scalar_one() or 0
|
||||
total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE)
|
||||
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
offset = (page - 1) * ORDERS_PER_PAGE
|
||||
stmt = (
|
||||
select(Order)
|
||||
.where(owner_clause)
|
||||
.order_by(Order.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(ORDERS_PER_PAGE)
|
||||
)
|
||||
if where_clause is not None:
|
||||
stmt = stmt.where(where_clause)
|
||||
|
||||
result = await g.s.execute(stmt)
|
||||
orders = result.scalars().all()
|
||||
|
||||
context = {
|
||||
"orders": orders,
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"search": search,
|
||||
"search_count": total_count,
|
||||
}
|
||||
|
||||
if not is_htmx_request():
|
||||
html = await render_template("_types/orders/index.html", **context)
|
||||
elif page > 1:
|
||||
html = await render_template("_types/orders/_rows.html", **context)
|
||||
else:
|
||||
html = await render_template("_types/orders/_oob_elements.html", **context)
|
||||
|
||||
resp = await make_response(html)
|
||||
resp.headers["Hx-Push-Url"] = _current_url_without_page()
|
||||
return _vary(resp)
|
||||
|
||||
return bp
|
||||
61
orders/entrypoint.sh
Normal file
61
orders/entrypoint.sh
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Optional: wait for Postgres to be reachable
|
||||
if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then
|
||||
echo "Waiting for Postgres at ${DATABASE_HOST}:${DATABASE_PORT}..."
|
||||
for i in {1..60}; do
|
||||
(echo > /dev/tcp/${DATABASE_HOST}/${DATABASE_PORT}) >/dev/null 2>&1 && break || true
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
# Create own database + run own migrations
|
||||
if [[ "${RUN_MIGRATIONS:-}" == "true" && -n "${ALEMBIC_DATABASE_URL:-}" ]]; then
|
||||
python3 -c "
|
||||
import os, re
|
||||
url = os.environ['ALEMBIC_DATABASE_URL']
|
||||
m = re.match(r'postgresql\+\w+://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)', url)
|
||||
if not m:
|
||||
print('Could not parse ALEMBIC_DATABASE_URL, skipping DB creation')
|
||||
exit(0)
|
||||
user, password, host, port, dbname = m.groups()
|
||||
|
||||
import psycopg
|
||||
conn = psycopg.connect(
|
||||
f'postgresql://{user}:{password}@{host}:{port}/postgres',
|
||||
autocommit=True,
|
||||
)
|
||||
cur = conn.execute('SELECT 1 FROM pg_database WHERE datname = %s', (dbname,))
|
||||
if not cur.fetchone():
|
||||
conn.execute(f'CREATE DATABASE {dbname}')
|
||||
print(f'Created database {dbname}')
|
||||
else:
|
||||
print(f'Database {dbname} already exists')
|
||||
conn.close()
|
||||
" || echo "DB creation failed (non-fatal), continuing..."
|
||||
|
||||
echo "Running orders Alembic migrations..."
|
||||
if [ -d orders ]; then (cd orders && alembic upgrade head); else alembic upgrade head; fi
|
||||
fi
|
||||
|
||||
# Clear Redis page cache on deploy
|
||||
if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then
|
||||
echo "Flushing Redis cache..."
|
||||
python3 -c "
|
||||
import redis, os
|
||||
r = redis.from_url(os.environ['REDIS_URL'])
|
||||
r.flushdb()
|
||||
print('Redis cache cleared.')
|
||||
" || echo "Redis flush failed (non-fatal), continuing..."
|
||||
fi
|
||||
|
||||
# Start the app
|
||||
RELOAD_FLAG=""
|
||||
if [[ "${RELOAD:-}" == "true" ]]; then
|
||||
RELOAD_FLAG="--reload"
|
||||
echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..."
|
||||
else
|
||||
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
|
||||
fi
|
||||
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000} --workers ${WORKERS:-2} --keep-alive 75 ${RELOAD_FLAG}
|
||||
1
orders/models/__init__.py
Normal file
1
orders/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from shared.models.order import Order, OrderItem
|
||||
9
orders/path_setup.py
Normal file
9
orders/path_setup.py
Normal file
@@ -0,0 +1,9 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
_app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
_project_root = os.path.dirname(_app_dir)
|
||||
|
||||
for _p in (_project_root, _app_dir):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
6
orders/services/__init__.py
Normal file
6
orders/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Orders app service registration."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the orders app."""
|
||||
63
orders/services/check_sumup_status.py
Normal file
63
orders/services/check_sumup_status.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Check SumUp checkout status and update order accordingly.
|
||||
|
||||
Moved from cart/bp/cart/services/check_sumup_status.py.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
|
||||
from shared.events import emit_activity
|
||||
from shared.infrastructure.actions import call_action
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
|
||||
|
||||
async def check_sumup_status(session, order, *, page_config=None):
|
||||
# Auto-fetch page_config from blog if order has one and caller didn't provide it
|
||||
if page_config is None and order.page_config_id:
|
||||
raw_pc = await fetch_data(
|
||||
"blog", "page-config-by-id",
|
||||
params={"id": order.page_config_id},
|
||||
required=False,
|
||||
)
|
||||
if raw_pc:
|
||||
page_config = SimpleNamespace(**raw_pc)
|
||||
|
||||
checkout_data = await sumup_get_checkout(order.sumup_checkout_id, page_config=page_config)
|
||||
order.sumup_status = checkout_data.get("status") or order.sumup_status
|
||||
sumup_status = (order.sumup_status or "").upper()
|
||||
|
||||
if sumup_status == "PAID":
|
||||
if order.status != "paid":
|
||||
order.status = "paid"
|
||||
await call_action("events", "confirm-entries-for-order", payload={
|
||||
"order_id": order.id, "user_id": order.user_id,
|
||||
"session_id": order.session_id,
|
||||
})
|
||||
await call_action("events", "confirm-tickets-for-order", payload={
|
||||
"order_id": order.id,
|
||||
})
|
||||
|
||||
page_post_id = page_config.container_id if page_config else None
|
||||
await call_action("cart", "clear-cart-for-order", payload={
|
||||
"user_id": order.user_id,
|
||||
"session_id": order.session_id,
|
||||
"page_post_id": page_post_id,
|
||||
})
|
||||
|
||||
await emit_activity(
|
||||
session,
|
||||
activity_type="rose:OrderPaid",
|
||||
actor_uri="internal:orders",
|
||||
object_type="rose:Order",
|
||||
object_data={
|
||||
"order_id": order.id,
|
||||
"user_id": order.user_id,
|
||||
},
|
||||
source_type="order",
|
||||
source_id=order.id,
|
||||
)
|
||||
elif sumup_status == "FAILED":
|
||||
order.status = "failed"
|
||||
else:
|
||||
order.status = sumup_status.lower() or order.status
|
||||
|
||||
await session.flush()
|
||||
147
orders/services/checkout.py
Normal file
147
orders/services/checkout.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Order creation and SumUp checkout helpers.
|
||||
|
||||
Moved from cart/bp/cart/services/checkout.py.
|
||||
Only the order-side logic lives here; find_or_create_cart_item stays in cart.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from shared.models.order import Order, OrderItem
|
||||
from shared.config import config
|
||||
from shared.events import emit_activity
|
||||
from shared.infrastructure.actions import call_action
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
|
||||
|
||||
async def resolve_page_config_from_post_id(post_id: int) -> Optional[SimpleNamespace]:
|
||||
"""Fetch the PageConfig for *post_id* from the blog service."""
|
||||
raw_pc = await fetch_data(
|
||||
"blog", "page-config",
|
||||
params={"container_type": "page", "container_id": post_id},
|
||||
required=False,
|
||||
)
|
||||
return SimpleNamespace(**raw_pc) if raw_pc else None
|
||||
|
||||
|
||||
async def create_order(
|
||||
session: AsyncSession,
|
||||
cart_items: list[dict],
|
||||
calendar_entries: list,
|
||||
user_id: Optional[int],
|
||||
session_id: Optional[str],
|
||||
product_total: float,
|
||||
calendar_total: float,
|
||||
*,
|
||||
ticket_total: float = 0,
|
||||
page_post_id: int | None = None,
|
||||
) -> Order:
|
||||
"""Create an Order + OrderItems from serialized cart data."""
|
||||
cart_total = product_total + calendar_total + ticket_total
|
||||
currency = (cart_items[0].get("product_price_currency") if cart_items else None) or "GBP"
|
||||
|
||||
order = Order(
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
status="pending",
|
||||
currency=currency,
|
||||
total_amount=cart_total,
|
||||
)
|
||||
session.add(order)
|
||||
await session.flush()
|
||||
|
||||
for ci in cart_items:
|
||||
price = ci.get("product_special_price") or ci.get("product_regular_price") or 0
|
||||
oi = OrderItem(
|
||||
order=order,
|
||||
product_id=ci["product_id"],
|
||||
product_title=ci.get("product_title"),
|
||||
product_slug=ci.get("product_slug"),
|
||||
product_image=ci.get("product_image"),
|
||||
quantity=ci.get("quantity", 1),
|
||||
unit_price=price,
|
||||
currency=currency,
|
||||
)
|
||||
session.add(oi)
|
||||
|
||||
await call_action("events", "claim-entries-for-order", payload={
|
||||
"order_id": order.id, "user_id": user_id,
|
||||
"session_id": session_id, "page_post_id": page_post_id,
|
||||
})
|
||||
await call_action("events", "claim-tickets-for-order", payload={
|
||||
"order_id": order.id, "user_id": user_id,
|
||||
"session_id": session_id, "page_post_id": page_post_id,
|
||||
})
|
||||
|
||||
await emit_activity(
|
||||
session,
|
||||
activity_type="Create",
|
||||
actor_uri="internal:orders",
|
||||
object_type="rose:Order",
|
||||
object_data={
|
||||
"order_id": order.id,
|
||||
"user_id": user_id,
|
||||
"session_id": session_id,
|
||||
},
|
||||
source_type="order",
|
||||
source_id=order.id,
|
||||
)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
def build_sumup_description(cart_items: list[dict], order_id: int, *, ticket_count: int = 0) -> str:
|
||||
titles = [ci.get("product_title") for ci in cart_items if ci.get("product_title")]
|
||||
item_count = sum(ci.get("quantity", 1) for ci in cart_items)
|
||||
parts = []
|
||||
if titles:
|
||||
if len(titles) <= 3:
|
||||
parts.append(", ".join(titles))
|
||||
else:
|
||||
parts.append(", ".join(titles[:3]) + f" + {len(titles) - 3} more")
|
||||
if ticket_count:
|
||||
parts.append(f"{ticket_count} ticket{'s' if ticket_count != 1 else ''}")
|
||||
summary = ", ".join(parts) if parts else "order items"
|
||||
total_count = item_count + ticket_count
|
||||
return f"Order {order_id} ({total_count} item{'s' if total_count != 1 else ''}): {summary}"
|
||||
|
||||
|
||||
def build_sumup_reference(order_id: int, page_config=None) -> str:
|
||||
if page_config and page_config.sumup_checkout_prefix:
|
||||
prefix = page_config.sumup_checkout_prefix
|
||||
else:
|
||||
sumup_cfg = config().get("sumup", {}) or {}
|
||||
prefix = sumup_cfg.get("checkout_reference_prefix", "")
|
||||
return f"{prefix}{order_id}"
|
||||
|
||||
|
||||
def build_webhook_url(base_url: str) -> str:
|
||||
sumup_cfg = config().get("sumup", {}) or {}
|
||||
webhook_secret = sumup_cfg.get("webhook_secret")
|
||||
if webhook_secret:
|
||||
sep = "&" if "?" in base_url else "?"
|
||||
return f"{base_url}{sep}{urlencode({'token': webhook_secret})}"
|
||||
return base_url
|
||||
|
||||
|
||||
def validate_webhook_secret(token: Optional[str]) -> bool:
|
||||
sumup_cfg = config().get("sumup", {}) or {}
|
||||
webhook_secret = sumup_cfg.get("webhook_secret")
|
||||
if not webhook_secret:
|
||||
return True
|
||||
return token is not None and token == webhook_secret
|
||||
|
||||
|
||||
async def get_order_with_details(session: AsyncSession, order_id: int) -> Optional[Order]:
|
||||
result = await session.execute(
|
||||
select(Order)
|
||||
.options(selectinload(Order.items))
|
||||
.where(Order.id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
38
orders/templates/_types/cart/checkout_error.html
Normal file
38
orders/templates/_types/cart/checkout_error.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{% extends '_types/root/index.html' %}
|
||||
|
||||
{% block filter %}
|
||||
<header class="mb-6 sm:mb-8">
|
||||
<h1 class="text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
Checkout error
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm text-stone-600">
|
||||
We tried to start your payment with SumUp but hit a problem.
|
||||
</p>
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-full px-3 py-3 space-y-4">
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">
|
||||
<p class="font-medium">Something went wrong.</p>
|
||||
<p>
|
||||
{{ error or "Unexpected error while creating the hosted checkout session." }}
|
||||
</p>
|
||||
{% if order %}
|
||||
<p class="text-xs text-rose-800/80">
|
||||
Order ID: <span class="font-mono">#{{ order.id }}</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a
|
||||
href="{{ cart_url('/') }}"
|
||||
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
|
||||
>
|
||||
<i class="fa fa-shopping-cart mr-2" aria-hidden="true"></i>
|
||||
Back to cart
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
68
orders/templates/_types/cart/checkout_return.html
Normal file
68
orders/templates/_types/cart/checkout_return.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends '_types/root/index.html' %}
|
||||
|
||||
{% block filter %}
|
||||
<header class="mb-1 sm:mb-2 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
{% if order.status == 'paid' %}
|
||||
Payment received
|
||||
{% elif order.status == 'failed' %}
|
||||
Payment failed
|
||||
{% elif order.status == 'missing' %}
|
||||
Order not found
|
||||
{% else %}
|
||||
Payment status: {{ order.status|default('pending')|capitalize }}
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm text-stone-600">
|
||||
{% if order.status == 'paid' %}
|
||||
Thanks for your order.
|
||||
{% elif order.status == 'failed' %}
|
||||
Something went wrong while processing your payment. You can try again below.
|
||||
{% elif order.status == 'missing' %}
|
||||
We couldn't find that order – it may have expired or never been created.
|
||||
{% else %}
|
||||
We’re still waiting for a final confirmation from SumUp.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
{% block aside %}
|
||||
{# no aside content for now #}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-full px-1 py-1">
|
||||
{% if order %}
|
||||
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2">
|
||||
{% include '_types/order/_summary.html' %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-rose-300 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-800">
|
||||
We couldn’t find that order. If you reached this page from an old link, please start a new order.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include '_types/order/_items.html' %}
|
||||
{% include '_types/order/_calendar_items.html' %}
|
||||
{% include '_types/order/_ticket_items.html' %}
|
||||
|
||||
{% if order.status == 'failed' and order %}
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">
|
||||
<p class="font-medium">Your payment was not completed.</p>
|
||||
<p>
|
||||
You can go back to your cart and try checkout again. If the problem persists,
|
||||
please contact us and mention order <span class="font-mono">#{{ order.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
{% elif order.status == 'paid' %}
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50/80 p-4 sm:p-6 text-sm text-emerald-900 space-y-2">
|
||||
<p class="font-medium">All done!</p>
|
||||
<p>We’ll start processing your order shortly.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
43
orders/templates/_types/order/_calendar_items.html
Normal file
43
orders/templates/_types/order/_calendar_items.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{# --- NEW: calendar bookings in this order --- #}
|
||||
{% if order and calendar_entries %}
|
||||
<section class="mt-6 space-y-3">
|
||||
<h2 class="text-base sm:text-lg font-semibold">
|
||||
Calendar bookings in this order
|
||||
</h2>
|
||||
|
||||
<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">
|
||||
{% for entry in calendar_entries %}
|
||||
<li class="px-4 py-3 flex items-start justify-between text-sm">
|
||||
<div>
|
||||
<div class="font-medium flex items-center gap-2">
|
||||
{{ entry.name }}
|
||||
{# Small status pill #}
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium
|
||||
{% if entry.state == 'confirmed' %}
|
||||
bg-emerald-100 text-emerald-800
|
||||
{% elif entry.state == 'provisional' %}
|
||||
bg-amber-100 text-amber-800
|
||||
{% elif entry.state == 'ordered' %}
|
||||
bg-blue-100 text-blue-800
|
||||
{% else %}
|
||||
bg-stone-100 text-stone-700
|
||||
{% endif %}
|
||||
">
|
||||
{{ entry.state|capitalize }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-stone-500">
|
||||
{{ entry.start_at.strftime('%-d %b %Y, %H:%M') }}
|
||||
{% if entry.end_at %}
|
||||
– {{ entry.end_at.strftime('%-d %b %Y, %H:%M') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 font-medium">
|
||||
£{{ "%.2f"|format(entry.cost or 0) }}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
51
orders/templates/_types/order/_items.html
Normal file
51
orders/templates/_types/order/_items.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{# Items list #}
|
||||
{% if order and order.items %}
|
||||
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6">
|
||||
<h2 class="text-sm sm:text-base font-semibold mb-3">
|
||||
Items
|
||||
</h2>
|
||||
<ul class="divide-y divide-stone-100 text-xs sm:text-sm">
|
||||
{% for item in order.items %}
|
||||
<li>
|
||||
<a class="w-full py-2 flex gap-3" href="{{ market_product_url(item.product_slug) }}">
|
||||
{# Thumbnail #}
|
||||
<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden">
|
||||
{% if item.product_image %}
|
||||
<img
|
||||
src="{{ item.product_image }}"
|
||||
alt="{{ item.product_title or 'Product image' }}"
|
||||
class="w-full h-full object-contain object-center"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
{% else %}
|
||||
<div class="w-full h-full flex items-center justify-center text-[9px] text-stone-400">
|
||||
No image
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Text + pricing #}
|
||||
<div class="flex-1 flex justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ item.product_title or 'Unknown product' }}
|
||||
</p>
|
||||
<p class="text-[11px] text-stone-500">
|
||||
Product ID: {{ item.product_id }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right whitespace-nowrap">
|
||||
<p>Qty: {{ item.quantity }}</p>
|
||||
<p>
|
||||
{{ item.currency or order.currency or 'GBP' }}
|
||||
{{ '%.2f'|format(item.unit_price or 0) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
7
orders/templates/_types/order/_main_panel.html
Normal file
7
orders/templates/_types/order/_main_panel.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="max-w-full px-3 py-3 space-y-4">
|
||||
{# Order summary card #}
|
||||
{% include '_types/order/_summary.html' %}
|
||||
{% include '_types/order/_items.html' %}
|
||||
{% include '_types/order/_calendar_items.html' %}
|
||||
|
||||
</div>
|
||||
2
orders/templates/_types/order/_nav.html
Normal file
2
orders/templates/_types/order/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
30
orders/templates/_types/order/_oob_elements.html
Normal file
30
orders/templates/_types/order/_oob_elements.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||
|
||||
{% block oobs %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('orders-header-child', 'order-header-child', '_types/order/header/_header.html')}}
|
||||
|
||||
{% from '_types/order/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/order/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/order/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
52
orders/templates/_types/order/_summary.html
Normal file
52
orders/templates/_types/order/_summary.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800">
|
||||
<p>
|
||||
<span class="font-medium">Order ID:</span>
|
||||
<span class="font-mono">#{{ order.id }}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span class="font-medium">Created:</span>
|
||||
{% if order.created_at %}
|
||||
{{ order.created_at.strftime('%-d %b %Y, %H:%M') }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span class="font-medium">Description:</span>
|
||||
{{ order.description or '–' }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span class="font-medium">Status:</span>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium
|
||||
{% if order.status == 'paid' %}
|
||||
bg-emerald-50 text-emerald-700 border border-emerald-200
|
||||
{% elif order.status == 'failed' %}
|
||||
bg-rose-50 text-rose-700 border border-rose-200
|
||||
{% else %}
|
||||
bg-stone-50 text-stone-700 border border-stone-200
|
||||
{% endif %}
|
||||
">
|
||||
{{ order.status or 'pending' }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span class="font-medium">Currency:</span>
|
||||
{{ order.currency or 'GBP' }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span class="font-medium">Total:</span>
|
||||
{% if order.total_amount %}
|
||||
{{ order.currency or 'GBP' }} {{ '%.2f'|format(order.total_amount) }}
|
||||
{% else %}
|
||||
–
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
49
orders/templates/_types/order/_ticket_items.html
Normal file
49
orders/templates/_types/order/_ticket_items.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{# --- Tickets in this order --- #}
|
||||
{% if order and order_tickets %}
|
||||
<section class="mt-6 space-y-3">
|
||||
<h2 class="text-base sm:text-lg font-semibold">
|
||||
Event tickets in this order
|
||||
</h2>
|
||||
|
||||
<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">
|
||||
{% for tk in order_tickets %}
|
||||
<li class="px-4 py-3 flex items-start justify-between text-sm">
|
||||
<div>
|
||||
<div class="font-medium flex items-center gap-2">
|
||||
{{ tk.entry_name }}
|
||||
{# Small status pill #}
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium
|
||||
{% if tk.state == 'confirmed' %}
|
||||
bg-emerald-100 text-emerald-800
|
||||
{% elif tk.state == 'reserved' %}
|
||||
bg-amber-100 text-amber-800
|
||||
{% elif tk.state == 'checked_in' %}
|
||||
bg-blue-100 text-blue-800
|
||||
{% else %}
|
||||
bg-stone-100 text-stone-700
|
||||
{% endif %}
|
||||
">
|
||||
{{ tk.state|replace('_', ' ')|capitalize }}
|
||||
</span>
|
||||
</div>
|
||||
{% if tk.ticket_type_name %}
|
||||
<div class="text-xs text-stone-500">{{ tk.ticket_type_name }}</div>
|
||||
{% endif %}
|
||||
<div class="text-xs text-stone-500">
|
||||
{{ tk.entry_start_at.strftime('%-d %b %Y, %H:%M') }}
|
||||
{% if tk.entry_end_at %}
|
||||
– {{ tk.entry_end_at.strftime('%-d %b %Y, %H:%M') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs text-stone-400 font-mono mt-0.5">
|
||||
{{ tk.code }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 font-medium">
|
||||
£{{ "%.2f"|format(tk.price or 0) }}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
17
orders/templates/_types/order/header/_header.html
Normal file
17
orders/templates/_types/order/header/_header.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='order-row', oob=oob) %}
|
||||
{% call links.link(url_for('orders.order.order_detail', order_id=order.id), hx_select_search ) %}
|
||||
<i class="fa fa-gbp" aria-hidden="true"></i>
|
||||
<div>
|
||||
Order
|
||||
</div>
|
||||
<div>
|
||||
{{ order.id }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/order/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
68
orders/templates/_types/order/index.html
Normal file
68
orders/templates/_types/order/index.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends '_types/orders/index.html' %}
|
||||
|
||||
|
||||
{% block orders_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('order-header-child', '_types/order/header/_header.html') %}
|
||||
{% block order_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/order/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block filter %}
|
||||
<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs sm:text-sm text-stone-600">
|
||||
Placed {% if order.created_at %}{{ order.created_at.strftime('%-d %b %Y, %H:%M') }}{% else %}—{% endif %} · Status: {{ order.status or 'pending' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex w-full sm:w-auto justify-start sm:justify-end gap-2">
|
||||
<a
|
||||
href="{{ url_for('orders.list_orders')|host }}"
|
||||
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
|
||||
>
|
||||
<i class="fa-solid fa-list mr-2" aria-hidden="true"></i>
|
||||
All orders
|
||||
</a>
|
||||
|
||||
{# Re-check status button #}
|
||||
<form
|
||||
method="post"
|
||||
action="{{ url_for('orders.order.order_recheck', order_id=order.id)|host }}"
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
|
||||
>
|
||||
<i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>
|
||||
Re-check status
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if order.status != 'paid' %}
|
||||
<a
|
||||
href="{{ url_for('orders.order.order_pay', order_id=order.id)|host }}"
|
||||
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
|
||||
>
|
||||
<i class="fa fa-credit-card mr-2" aria-hidden="true"></i>
|
||||
Open payment page
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/order/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block aside %}
|
||||
{% endblock %}
|
||||
26
orders/templates/_types/orders/_main_panel.html
Normal file
26
orders/templates/_types/orders/_main_panel.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<div class="max-w-full px-3 py-3 space-y-3">
|
||||
{% if not orders %}
|
||||
<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700">
|
||||
No orders yet.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="overflow-x-auto rounded-2xl border border-stone-200 bg-white/80">
|
||||
<table class="min-w-full text-xs sm:text-sm">
|
||||
<thead class="bg-stone-50 border-b border-stone-200 text-stone-600">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-medium">Order</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Created</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Description</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Total</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Status</th>
|
||||
<th class="px-3 py-2 text-left font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{# rows + infinite-scroll sentinel #}
|
||||
{% include "_types/orders/_rows.html" %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
2
orders/templates/_types/orders/_nav.html
Normal file
2
orders/templates/_types/orders/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
38
orders/templates/_types/orders/_oob_elements.html
Normal file
38
orders/templates/_types/orders/_oob_elements.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||
|
||||
{% block oobs %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('auth-header-child', 'orders-header-child', '_types/orders/header/_header.html')}}
|
||||
|
||||
{% from '_types/auth/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block aside %}
|
||||
{% from 'macros/search.html' import search_desktop %}
|
||||
{{ search_desktop(current_local_href, search, search_count, hx_select) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block filter %}
|
||||
{% include '_types/orders/_summary.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/orders/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/orders/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
164
orders/templates/_types/orders/_rows.html
Normal file
164
orders/templates/_types/orders/_rows.html
Normal file
@@ -0,0 +1,164 @@
|
||||
{# suma_browser/templates/_types/order/_orders_rows.html #}
|
||||
|
||||
{# --- existing rows, but split into desktop/tablet vs mobile --- #}
|
||||
{% for order in orders %}
|
||||
{# Desktop / tablet table row #}
|
||||
<tr class="hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60">
|
||||
<td class="px-3 py-2 align-top">
|
||||
<span class="font-mono text-[11px] sm:text-xs">#{{ order.id }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">
|
||||
{% if order.created_at %}
|
||||
{{ order.created_at.strftime('%-d %b %Y, %H:%M') }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">
|
||||
{{ order.description or '' }}
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">
|
||||
{{ order.currency or 'GBP' }}
|
||||
{{ '%.2f'|format(order.total_amount or 0) }}
|
||||
</td>
|
||||
<td class="px-3 py-2 align-top">
|
||||
{# status pill, roughly matching existing styling #}
|
||||
<span
|
||||
class="
|
||||
inline-flex items-center rounded-full border px-2 py-0.5
|
||||
text-[11px] sm:text-xs
|
||||
{% if (order.status or '').lower() == 'paid' %}
|
||||
border-emerald-300 bg-emerald-50 text-emerald-700
|
||||
{% elif (order.status or '').lower() in ['failed', 'cancelled'] %}
|
||||
border-rose-300 bg-rose-50 text-rose-700
|
||||
{% else %}
|
||||
border-stone-300 bg-stone-50 text-stone-700
|
||||
{% endif %}
|
||||
"
|
||||
>
|
||||
{{ order.status or 'pending' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-0.5 align-top text-right">
|
||||
<a
|
||||
href="{{ url_for('orders.order.order_detail', order_id=order.id)|host }}"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
|
||||
>
|
||||
View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{# Mobile card row #}
|
||||
<tr class="sm:hidden border-t border-stone-100">
|
||||
<td colspan="5" class="px-3 py-3">
|
||||
<div class="flex flex-col gap-2 text-xs">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-[11px] text-stone-700">
|
||||
#{{ order.id }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="
|
||||
inline-flex items-center rounded-full border px-2 py-0.5
|
||||
text-[11px]
|
||||
{% if (order.status or '').lower() == 'paid' %}
|
||||
border-emerald-300 bg-emerald-50 text-emerald-700
|
||||
{% elif (order.status or '').lower() in ['failed', 'cancelled'] %}
|
||||
border-rose-300 bg-rose-50 text-rose-700
|
||||
{% else %}
|
||||
border-stone-300 bg-stone-50 text-stone-700
|
||||
{% endif %}
|
||||
"
|
||||
>
|
||||
{{ order.status or 'pending' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="text-[11px] text-stone-500 break-words">
|
||||
{{ order.created_at or '' }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="font-medium text-stone-800">
|
||||
{{ order.currency or 'GBP' }}
|
||||
{{ '%.2f'|format(order.total_amount or 0) }}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="{{ url_for('orders.order.order_detail', order_id=order.id)|host }}"
|
||||
class="inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0"
|
||||
>
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{# --- sentinel / end-of-results --- #}
|
||||
{% if page < total_pages|int %}
|
||||
<tr
|
||||
id="orders-sentinel-{{ page }}"
|
||||
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
|
||||
hx-trigger="intersect once delay:250ms, sentinel:retry"
|
||||
hx-swap="outerHTML"
|
||||
_="
|
||||
init
|
||||
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
|
||||
|
||||
on sentinel:retry
|
||||
remove .hidden from .js-loading in me
|
||||
add .hidden to .js-neterr in me
|
||||
set me.style.pointerEvents to 'none'
|
||||
set me.style.opacity to '0'
|
||||
trigger htmx:consume on me
|
||||
call htmx.trigger(me, 'intersect')
|
||||
end
|
||||
|
||||
def backoff()
|
||||
add .hidden to .js-loading in me
|
||||
remove .hidden from .js-neterr in me
|
||||
set myMs to Number(me.dataset.retryMs)
|
||||
if myMs < 10000 then set me.dataset.retryMs to myMs * 2 end
|
||||
js setTimeout(() => htmx.trigger(me, 'sentinel:retry'), myMs)
|
||||
end
|
||||
|
||||
on htmx:beforeRequest
|
||||
set me.style.pointerEvents to 'none'
|
||||
set me.style.opacity to '0'
|
||||
end
|
||||
|
||||
on htmx:afterSwap
|
||||
set me.dataset.retryMs to 1000
|
||||
end
|
||||
|
||||
on htmx:sendError call backoff()
|
||||
on htmx:responseError call backoff()
|
||||
on htmx:timeout call backoff()
|
||||
"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<td colspan="5" class="px-3 py-4">
|
||||
{# Mobile sentinel content #}
|
||||
<div class="block md:hidden h-[60vh] js-mobile-sentinel">
|
||||
{% include "sentinel/mobile_content.html" %}
|
||||
</div>
|
||||
|
||||
{# Desktop sentinel content #}
|
||||
<div class="hidden md:block h-[30vh] js-desktop-sentinel">
|
||||
{% include "sentinel/desktop_content.html" %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="px-3 py-4 text-center text-xs text-stone-400">
|
||||
End of results
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
11
orders/templates/_types/orders/_summary.html
Normal file
11
orders/templates/_types/orders/_summary.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs sm:text-sm text-stone-600">
|
||||
Recent orders placed via the checkout.
|
||||
</p>
|
||||
</div>
|
||||
<div class="md:hidden">
|
||||
{% from 'macros/search.html' import search_mobile %}
|
||||
{{ search_mobile(current_local_href, search, search_count, hx_select) }}
|
||||
</div>
|
||||
</header>
|
||||
14
orders/templates/_types/orders/header/_header.html
Normal file
14
orders/templates/_types/orders/header/_header.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='orders-row', oob=oob) %}
|
||||
{% call links.link(url_for('orders.list_orders'), hx_select_search, ) %}
|
||||
<i class="fa fa-gbp" aria-hidden="true"></i>
|
||||
<div>
|
||||
Orders
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/orders/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
29
orders/templates/_types/orders/index.html
Normal file
29
orders/templates/_types/orders/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends '_types/auth/index.html' %}
|
||||
|
||||
|
||||
{% block auth_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('orders-header-child', '_types/orders/header/_header.html') %}
|
||||
{% block orders_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/orders/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block aside %}
|
||||
{% from 'macros/search.html' import search_desktop %}
|
||||
{{ search_desktop(current_local_href, search, search_count, hx_select) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block filter %}
|
||||
{% include '_types/orders/_summary.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/orders/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user