Split cart into 4 microservices: relations, likes, orders, page-config→blog
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:
2026-02-27 09:03:33 +00:00
parent 76a9436ea1
commit fa431ee13e
125 changed files with 3459 additions and 860 deletions

View File

@@ -38,6 +38,12 @@ 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/
COPY orders/__init__.py ./orders/__init__.py
COPY orders/models/ ./orders/models/
# ---------- Runtime setup ----------
COPY cart/entrypoint.sh /usr/local/bin/entrypoint.sh

View File

@@ -3,13 +3,10 @@ from shared.db.alembic_env import run_alembic
MODELS = [
"shared.models.market", # CartItem lives here
"shared.models.order",
"shared.models.page_config",
"shared.models.container_relation",
]
TABLES = frozenset({
"cart_items", "orders", "order_items", "page_configs", "container_relations",
"cart_items",
})
run_alembic(context.config, MODELS, TABLES)

View File

@@ -15,7 +15,6 @@ from bp import (
register_cart_overview,
register_page_cart,
register_cart_global,
register_orders,
register_fragments,
register_actions,
register_data,
@@ -121,7 +120,6 @@ def _make_page_config(raw: dict) -> SimpleNamespace:
def create_app() -> "Quart":
from shared.services.registry import services
from services import register_domain_services
app = create_base_app(
@@ -184,10 +182,7 @@ def create_app() -> "Quart":
# --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last
# Orders blueprint
app.register_blueprint(register_orders(url_prefix="/orders"))
# Global routes (webhook, return, add — specific paths under /)
# Global routes (add, quantity, delete, checkout — specific paths under /)
app.register_blueprint(
register_cart_global(url_prefix="/"),
url_prefix="/",
@@ -205,65 +200,6 @@ def create_app() -> "Quart":
url_prefix="/<page_slug>",
)
# --- 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.
Handles the case where SumUp webhooks fired while the service was down
or were rejected (e.g. CSRF). Runs once on boot.
"""
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 shared.infrastructure.data_client import fetch_data
from bp.cart.services.check_sumup_status import check_sumup_status
log = logging.getLogger("cart.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:
# Fetch page_config from blog if order has one
pc = None
if 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:
pc = _make_page_config(raw_pc)
await check_sumup_status(sess, order, page_config=pc)
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

View File

@@ -1,8 +1,6 @@
from .cart.overview_routes import register as register_cart_overview
from .cart.page_routes import register as register_page_cart
from .cart.global_routes import register as register_cart_global
from .order.routes import register as register_order
from .orders.routes import register as register_orders
from .fragments import register_fragments
from .actions import register_actions
from .data import register_data

View File

@@ -47,109 +47,26 @@ def register() -> Blueprint:
_handlers["adopt-cart-for-user"] = _adopt_cart
# --- update-page-config ---
async def _update_page_config():
"""Create or update a PageConfig (page_configs lives in db_cart)."""
from shared.models.page_config import PageConfig
from sqlalchemy import select
from sqlalchemy.orm.attributes import flag_modified
# --- clear-cart-for-order ---
async def _clear_cart_for_order():
"""Soft-delete cart items after an order is paid. Called by orders service."""
from bp.cart.services.clear_cart_for_order import clear_cart_for_order
from shared.models.order import Order
data = await request.get_json(force=True)
container_type = data.get("container_type", "page")
container_id = data.get("container_id")
if container_id is None:
return {"error": "container_id required"}, 400
data = await request.get_json()
user_id = data.get("user_id")
session_id = data.get("session_id")
page_post_id = data.get("page_post_id")
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
# Build a minimal order-like object with the fields clear_cart_for_order needs
order = type("_Order", (), {
"user_id": user_id,
"session_id": session_id,
})()
if pc is None:
pc = PageConfig(
container_type=container_type,
container_id=container_id,
features=data.get("features", {}),
)
g.s.add(pc)
await g.s.flush()
await clear_cart_for_order(g.s, order, page_post_id=page_post_id)
return {"ok": True}
if "features" in data:
features = dict(pc.features or {})
for key, val in data["features"].items():
if isinstance(val, bool):
features[key] = val
elif val in ("true", "1", "on"):
features[key] = True
elif val in ("false", "0", "off", None):
features[key] = False
pc.features = features
flag_modified(pc, "features")
if "sumup_merchant_code" in data:
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
if "sumup_checkout_prefix" in data:
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
if "sumup_api_key" in data:
pc.sumup_api_key = data["sumup_api_key"] or None
await g.s.flush()
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
"sumup_configured": bool(pc.sumup_api_key),
}
_handlers["update-page-config"] = _update_page_config
# --- attach-child ---
async def _attach_child():
"""Create or revive a ContainerRelation."""
from shared.services.relationships import attach_child
data = await request.get_json(force=True)
rel = await attach_child(
g.s,
parent_type=data["parent_type"],
parent_id=data["parent_id"],
child_type=data["child_type"],
child_id=data["child_id"],
label=data.get("label"),
sort_order=data.get("sort_order"),
)
return {
"id": rel.id,
"parent_type": rel.parent_type,
"parent_id": rel.parent_id,
"child_type": rel.child_type,
"child_id": rel.child_id,
"sort_order": rel.sort_order,
}
_handlers["attach-child"] = _attach_child
# --- detach-child ---
async def _detach_child():
"""Soft-delete a ContainerRelation."""
from shared.services.relationships import detach_child
data = await request.get_json(force=True)
deleted = await detach_child(
g.s,
parent_type=data["parent_type"],
parent_id=data["parent_id"],
child_type=data["child_type"],
child_id=data["child_id"],
)
return {"deleted": deleted}
_handlers["detach-child"] = _detach_child
_handlers["clear-cart-for-order"] = _clear_cart_for_order
return bp

View File

@@ -1,4 +1,4 @@
# bp/cart/global_routes.py — Global cart routes (webhook, return, add)
# bp/cart/global_routes.py — Global cart routes (add, quantity, delete, checkout)
from __future__ import annotations
@@ -6,7 +6,6 @@ from quart import Blueprint, g, request, render_template, redirect, url_for, mak
from sqlalchemy import select
from shared.models.market import CartItem
from shared.models.order import Order
from shared.infrastructure.actions import call_action
from .services import (
current_cart_identity,
@@ -16,20 +15,11 @@ from .services import (
calendar_total,
get_ticket_cart_entries,
ticket_total,
check_sumup_status,
)
from .services.checkout import (
find_or_create_cart_item,
create_order_from_cart,
resolve_page_config,
build_sumup_description,
build_sumup_reference,
build_webhook_url,
validate_webhook_secret,
get_order_with_details,
)
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.browser.app.csrf import csrf_exempt
def register(url_prefix: str) -> Blueprint:
@@ -141,7 +131,7 @@ def register(url_prefix: str) -> Blueprint:
@bp.post("/checkout/")
async def checkout():
"""Legacy global checkout (for orphan items without page scope)."""
"""Global checkout — delegates order creation to orders service."""
cart = await get_cart(g.s)
calendar_entries = await get_calendar_cart_entries(g.s)
tickets = await get_ticket_cart_entries(g.s)
@@ -168,151 +158,63 @@ def register(url_prefix: str) -> Blueprint:
return await make_response(html, 400)
ident = current_cart_identity()
order = await create_order_from_cart(
g.s,
cart,
calendar_entries,
ident.get("user_id"),
ident.get("session_id"),
product_total,
calendar_amount,
ticket_total=ticket_amount,
)
# Serialize cart items for the orders service
cart_items_data = []
for ci in cart:
cart_items_data.append({
"product_id": ci.product_id,
"product_title": ci.product_title,
"product_slug": ci.product_slug,
"product_image": ci.product_image,
"product_regular_price": float(ci.product_regular_price) if ci.product_regular_price else None,
"product_special_price": float(ci.product_special_price) if ci.product_special_price else None,
"product_price_currency": ci.product_price_currency,
"quantity": ci.quantity,
})
# Serialize calendar entries and tickets
cal_data = []
for e in calendar_entries:
cal_data.append({
"id": e.id,
"calendar_container_id": getattr(e, "calendar_container_id", None),
})
ticket_data = []
for t in tickets:
ticket_data.append({
"id": t.id,
"calendar_container_id": getattr(t, "calendar_container_id", None),
})
page_post_id = None
if page_config:
order.page_config_id = page_config.id
page_post_id = getattr(page_config, "container_id", None)
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
description = build_sumup_description(cart, order.id, ticket_count=len(tickets))
result = await call_action("orders", "create-order", payload={
"cart_items": cart_items_data,
"calendar_entries": cal_data,
"tickets": ticket_data,
"user_id": ident.get("user_id"),
"session_id": ident.get("session_id"),
"product_total": float(product_total),
"calendar_total": float(calendar_amount),
"ticket_total": float(ticket_amount),
"page_post_id": page_post_id,
})
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
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()
# Update redirect/webhook URLs with real order_id
order_id = result["order_id"]
hosted_url = result.get("sumup_hosted_url")
if not hosted_url:
html = await render_template(
"_types/cart/checkout_error.html",
order=order,
order=None,
error="No hosted checkout URL returned from SumUp.",
)
return await make_response(html, 500)
return redirect(hosted_url)
@csrf_exempt
@bp.post("/checkout/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("/checkout/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)
# Resolve page/market slugs so product links render correctly
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("cart", "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"]
# Fetch marketplace slug from market service
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

View File

@@ -5,8 +5,7 @@ from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, make_response, url_for
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config
from shared.infrastructure.actions import call_action
from .services import (
total,
calendar_total,
@@ -14,12 +13,6 @@ from .services import (
)
from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page
from .services.ticket_groups import group_tickets
from .services.checkout import (
create_order_from_cart,
build_sumup_description,
build_sumup_reference,
build_webhook_url,
)
from .services import current_cart_identity
@@ -56,7 +49,6 @@ def register(url_prefix: str) -> Blueprint:
@bp.post("/checkout/")
async def page_checkout():
post = g.page_post
page_config = getattr(g, "page_config", None)
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
@@ -65,61 +57,51 @@ def register(url_prefix: str) -> Blueprint:
if not cart and not cal_entries and not page_tickets:
return redirect(url_for("page_cart.page_view"))
product_total = total(cart) or 0
product_total_val = total(cart) or 0
calendar_amount = calendar_total(cal_entries) or 0
ticket_amount = ticket_total(page_tickets) or 0
cart_total = product_total + calendar_amount + ticket_amount
cart_total = product_total_val + calendar_amount + ticket_amount
if cart_total <= 0:
return redirect(url_for("page_cart.page_view"))
# Create order scoped to this page
ident = current_cart_identity()
order = await create_order_from_cart(
g.s,
cart,
cal_entries,
ident.get("user_id"),
ident.get("session_id"),
product_total,
calendar_amount,
ticket_total=ticket_amount,
page_post_id=post.id,
)
# Set page_config on order
if page_config:
order.page_config_id = page_config.id
# Serialize cart items for the orders service
cart_items_data = []
for ci in cart:
cart_items_data.append({
"product_id": ci.product_id,
"product_title": ci.product_title,
"product_slug": ci.product_slug,
"product_image": ci.product_image,
"product_regular_price": float(ci.product_regular_price) if ci.product_regular_price else None,
"product_special_price": float(ci.product_special_price) if ci.product_special_price else None,
"product_price_currency": ci.product_price_currency,
"quantity": ci.quantity,
})
# Build SumUp checkout details — webhook/return use global routes
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
description = build_sumup_description(cart, order.id, ticket_count=len(page_tickets))
cal_data = [{"id": e.id, "calendar_container_id": getattr(e, "calendar_container_id", None)} for e in cal_entries]
ticket_data = [{"id": t.id, "calendar_container_id": getattr(t, "calendar_container_id", None)} for t in page_tickets]
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
webhook_url = build_webhook_url(webhook_base_url)
result = await call_action("orders", "create-order", payload={
"cart_items": cart_items_data,
"calendar_entries": cal_data,
"tickets": ticket_data,
"user_id": ident.get("user_id"),
"session_id": ident.get("session_id"),
"product_total": float(product_total_val),
"calendar_total": float(calendar_amount),
"ticket_total": float(ticket_amount),
"page_post_id": post.id,
})
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()
hosted_url = result.get("sumup_hosted_url")
if not hosted_url:
html = await render_template(
"_types/cart/checkout_error.html",
order=order,
order=None,
error="No hosted checkout URL returned from SumUp.",
)
return await make_response(html, 500)

View File

@@ -1,9 +1,7 @@
from .get_cart import get_cart
from .identity import current_cart_identity
from .total import total
from .clear_cart_for_order import clear_cart_for_order
from .calendar_cart import get_calendar_cart_entries, calendar_total, get_ticket_cart_entries, ticket_total
from .check_sumup_status import check_sumup_status
from .page_cart import (
get_cart_for_page,
get_calendar_entries_for_page,

View File

@@ -178,7 +178,7 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
p = dto_from_dict(PostDTO, raw_p)
posts_by_id[p.id] = p
raw_pcs = await fetch_data("cart", "page-configs-batch",
raw_pcs = await fetch_data("blog", "page-configs-batch",
params={"container_type": "page",
"ids": ",".join(str(i) for i in post_ids)},
required=False) or []

View File

@@ -45,41 +45,6 @@ def register() -> Blueprint:
_handlers["cart-summary"] = _cart_summary
# --- page-config-ensure ---
async def _page_config_ensure():
"""Get or create a PageConfig for a container_type + container_id."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
container_type = request.args.get("container_type", "page")
container_id = request.args.get("container_id", type=int)
if container_id is None:
return {"error": "container_id required"}, 400
row = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if row is None:
row = PageConfig(
container_type=container_type,
container_id=container_id,
features={},
)
g.s.add(row)
await g.s.flush()
return {
"id": row.id,
"container_type": row.container_type,
"container_id": row.container_id,
}
_handlers["page-config-ensure"] = _page_config_ensure
# --- cart-items (product slugs + quantities for template rendering) ---
async def _cart_items():
from sqlalchemy import select
@@ -111,103 +76,4 @@ def register() -> Blueprint:
_handlers["cart-items"] = _cart_items
# --- page-config ---
async def _page_config():
"""Return a single PageConfig by container_type + container_id."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
ct = request.args.get("container_type", "page")
cid = request.args.get("container_id", type=int)
if cid is None:
return None
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id == cid,
)
)).scalar_one_or_none()
if not pc:
return None
return _page_config_dict(pc)
_handlers["page-config"] = _page_config
# --- page-config-by-id ---
async def _page_config_by_id():
"""Return a single PageConfig by its primary key."""
from shared.models.page_config import PageConfig
pc_id = request.args.get("id", type=int)
if pc_id is None:
return None
pc = await g.s.get(PageConfig, pc_id)
if not pc:
return None
return _page_config_dict(pc)
_handlers["page-config-by-id"] = _page_config_by_id
# --- page-configs-batch ---
async def _page_configs_batch():
"""Return PageConfigs for multiple container_ids (comma-separated)."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
ct = request.args.get("container_type", "page")
ids_raw = request.args.get("ids", "")
if not ids_raw:
return []
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
if not ids:
return []
result = await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id.in_(ids),
)
)
return [_page_config_dict(pc) for pc in result.scalars().all()]
_handlers["page-configs-batch"] = _page_configs_batch
# --- get-children ---
async def _get_children():
"""Return ContainerRelation children for a parent."""
from shared.services.relationships import get_children
parent_type = request.args.get("parent_type", "")
parent_id = request.args.get("parent_id", type=int)
child_type = request.args.get("child_type")
if not parent_type or parent_id is None:
return []
rels = await get_children(g.s, parent_type, parent_id, child_type)
return [
{
"id": r.id,
"parent_type": r.parent_type,
"parent_id": r.parent_id,
"child_type": r.child_type,
"child_id": r.child_id,
"sort_order": r.sort_order,
"label": r.label,
}
for r in rels
]
_handlers["get-children"] = _get_children
return bp
def _page_config_dict(pc) -> dict:
"""Serialize PageConfig to a JSON-safe dict."""
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_api_key": pc.sumup_api_key,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
}

View File

@@ -1,2 +0,0 @@
from .order import Order, OrderItem
from .page_config import PageConfig