Decouple cart/market DBs: denormalize product data, AP internal inbox, OAuth scraper auth

Remove cross-DB relationships (CartItem.product, CartItem.market_place,
OrderItem.product) that break with per-service databases. Denormalize
product and marketplace fields onto cart_items/order_items at write time.

- Add AP internal inbox infrastructure (shared/infrastructure/internal_inbox*)
  for synchronous inter-service writes via HMAC-authenticated POST
- Cart inbox blueprint handles Add/Remove/Update rose:CartItem activities
- Market app sends AP activities to cart inbox instead of writing CartItem directly
- Cart services use denormalized columns instead of cross-DB hydration/joins
- Add marketplaces-by-ids data endpoint to market service
- Alembic migration adds denormalized columns to cart_items and order_items
- Add OAuth device flow auth to market scraper persist_api (artdag client pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 14:49:04 +00:00
parent cf7fbd8e9b
commit 81112c716b
28 changed files with 739 additions and 186 deletions

View File

@@ -0,0 +1,48 @@
"""Denormalize product and marketplace data onto cart_items and order_items.
Revision ID: cart_0002
Revises: cart_0001
Create Date: 2026-02-26
"""
import sqlalchemy as sa
from alembic import op
revision = "cart_0002"
down_revision = "cart_0001"
branch_labels = None
depends_on = None
def upgrade():
# -- cart_items: denormalized product data --
op.add_column("cart_items", sa.Column("product_title", sa.String(512), nullable=True))
op.add_column("cart_items", sa.Column("product_slug", sa.String(512), nullable=True))
op.add_column("cart_items", sa.Column("product_image", sa.Text, nullable=True))
op.add_column("cart_items", sa.Column("product_brand", sa.String(255), nullable=True))
op.add_column("cart_items", sa.Column("product_regular_price", sa.Numeric(12, 2), nullable=True))
op.add_column("cart_items", sa.Column("product_special_price", sa.Numeric(12, 2), nullable=True))
op.add_column("cart_items", sa.Column("product_price_currency", sa.String(16), nullable=True))
# -- cart_items: denormalized marketplace data --
op.add_column("cart_items", sa.Column("market_place_name", sa.String(255), nullable=True))
op.add_column("cart_items", sa.Column("market_place_container_id", sa.Integer, nullable=True))
# -- order_items: denormalized product fields --
op.add_column("order_items", sa.Column("product_slug", sa.String(512), nullable=True))
op.add_column("order_items", sa.Column("product_image", sa.Text, nullable=True))
def downgrade():
op.drop_column("order_items", "product_image")
op.drop_column("order_items", "product_slug")
op.drop_column("cart_items", "market_place_container_id")
op.drop_column("cart_items", "market_place_name")
op.drop_column("cart_items", "product_price_currency")
op.drop_column("cart_items", "product_special_price")
op.drop_column("cart_items", "product_regular_price")
op.drop_column("cart_items", "product_brand")
op.drop_column("cart_items", "product_image")
op.drop_column("cart_items", "product_slug")
op.drop_column("cart_items", "product_title")

View File

@@ -19,6 +19,7 @@ from bp import (
register_fragments,
register_actions,
register_data,
register_inbox,
)
from bp.cart.services import (
get_cart,
@@ -143,6 +144,7 @@ def create_app() -> "Quart":
app.register_blueprint(register_fragments())
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
app.register_blueprint(register_inbox())
# --- Page slug hydration (follows events/market app pattern) ---

View File

@@ -6,3 +6,4 @@ from .orders.routes import register as register_orders
from .fragments import register_fragments
from .actions import register_actions
from .data import register_data
from .inbox import register_inbox

View File

@@ -7,7 +7,6 @@ from sqlalchemy import select
from shared.models.market import CartItem
from shared.models.order import Order
from shared.models.market_place import MarketPlace
from shared.infrastructure.actions import call_action
from .services import (
current_cart_identity,
@@ -265,16 +264,14 @@ def register(url_prefix: str) -> Blueprint:
required=False) if raw_pc else None
if post:
g.page_slug = post["slug"]
result = await g.s.execute(
select(MarketPlace).where(
MarketPlace.container_type == "page",
MarketPlace.container_id == post["id"],
MarketPlace.deleted_at.is_(None),
).limit(1)
)
mp = result.scalar_one_or_none()
if mp:
g.market_slug = mp.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:

View File

@@ -9,9 +9,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from shared.models.market import Product, CartItem
from shared.models.market import CartItem
from shared.models.order import Order, OrderItem
from shared.models.market_place import MarketPlace
from shared.config import config
from shared.contracts.dtos import CalendarEntryDTO
from shared.events import emit_activity
@@ -24,17 +23,24 @@ async def find_or_create_cart_item(
product_id: int,
user_id: Optional[int],
session_id: Optional[str],
*,
product_title: str | None = None,
product_slug: str | None = None,
product_image: str | None = None,
product_brand: str | None = None,
product_regular_price: float | None = None,
product_special_price: float | None = None,
product_price_currency: str | None = None,
market_place_id: int | None = None,
market_place_name: str | None = None,
market_place_container_id: int | None = None,
) -> Optional[CartItem]:
"""
Find an existing cart item for this product/identity, or create a new one.
Returns None if the product doesn't exist.
Returns None if product data is missing.
Increments quantity if item already exists.
"""
# Make sure product exists
product = await session.scalar(
select(Product).where(Product.id == product_id)
)
if not product:
if not product_id:
return None
# Look for existing cart item
@@ -56,8 +62,18 @@ async def find_or_create_cart_item(
cart_item = CartItem(
user_id=user_id,
session_id=session_id,
product_id=product.id,
product_id=product_id,
quantity=1,
market_place_id=market_place_id,
product_title=product_title,
product_slug=product_slug,
product_image=product_image,
product_brand=product_brand,
product_regular_price=product_regular_price,
product_special_price=product_special_price,
product_price_currency=product_price_currency,
market_place_name=market_place_name,
market_place_container_id=market_place_container_id,
)
session.add(cart_item)
return cart_item
@@ -76,12 +92,10 @@ async def resolve_page_config(
"""
post_ids: set[int] = set()
# From cart items via market_place
# From cart items via denormalized market_place_container_id
for ci in cart:
if ci.market_place_id:
mp = await session.get(MarketPlace, ci.market_place_id)
if mp:
post_ids.add(mp.container_id)
if ci.market_place_container_id:
post_ids.add(ci.market_place_container_id)
# From calendar entries via calendar
for entry in calendar_entries:
@@ -130,8 +144,7 @@ async def create_order_from_cart(
cart_total = product_total + calendar_total + ticket_total
# Determine currency from first product
first_product = cart[0].product if cart else None
currency = (first_product.regular_price_currency if first_product else None) or "GBP"
currency = (cart[0].product_price_currency if cart else None) or "GBP"
# Create order
order = Order(
@@ -146,11 +159,13 @@ async def create_order_from_cart(
# Create order items from cart
for ci in cart:
price = ci.product.special_price or ci.product.regular_price or 0
price = ci.product_special_price or ci.product_regular_price or 0
oi = OrderItem(
order=order,
product_id=ci.product.id,
product_title=ci.product.title,
product_id=ci.product_id,
product_title=ci.product_title,
product_slug=ci.product_slug,
product_image=ci.product_image,
quantity=ci.quantity,
unit_price=price,
currency=currency,
@@ -188,7 +203,7 @@ async def create_order_from_cart(
def build_sumup_description(cart: list[CartItem], order_id: int, *, ticket_count: int = 0) -> str:
"""Build a human-readable description for SumUp checkout."""
titles = [ci.product.title for ci in cart if ci.product and ci.product.title]
titles = [ci.product_title for ci in cart if ci.product_title]
item_count = sum(ci.quantity for ci in cart)
parts = []
@@ -240,11 +255,11 @@ def validate_webhook_secret(token: Optional[str]) -> bool:
async def get_order_with_details(session: AsyncSession, order_id: int) -> Optional[Order]:
"""Fetch an order with items and calendar entries eagerly loaded."""
"""Fetch an order with items eagerly loaded."""
result = await session.execute(
select(Order)
.options(
selectinload(Order.items).selectinload(OrderItem.product),
selectinload(Order.items),
)
.where(Order.id == order_id)
)

View File

@@ -1,7 +1,6 @@
from sqlalchemy import update, func, select
from sqlalchemy import update, func
from shared.models.market import CartItem
from shared.models.market_place import MarketPlace
from shared.models.order import Order
@@ -23,12 +22,7 @@ async def clear_cart_for_order(session, order: Order, *, page_post_id: int | Non
return
if page_post_id is not None:
mp_ids = select(MarketPlace.id).where(
MarketPlace.container_type == "page",
MarketPlace.container_id == page_post_id,
MarketPlace.deleted_at.is_(None),
).scalar_subquery()
filters.append(CartItem.market_place_id.in_(mp_ids))
filters.append(CartItem.market_place_container_id == page_post_id)
await session.execute(
update(CartItem)

View File

@@ -1,25 +1,55 @@
from types import SimpleNamespace
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.market import CartItem
from .identity import current_cart_identity
async def get_cart(session):
ident = current_cart_identity()
filters = [CartItem.deleted_at.is_(None)]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
else:
filters.append(CartItem.session_id == ident["session_id"])
result = await session.execute(
select(CartItem)
.where(*filters)
.order_by(CartItem.created_at.desc())
.options(
selectinload(CartItem.product),
selectinload(CartItem.market_place),
)
)
return result.scalars().all()
def _attach_product_namespace(ci: CartItem) -> None:
"""Build a SimpleNamespace 'product' from denormalized columns for template compat."""
ci.product = SimpleNamespace(
id=ci.product_id,
title=ci.product_title,
slug=ci.product_slug,
image=ci.product_image,
brand=ci.product_brand,
regular_price=ci.product_regular_price,
special_price=ci.product_special_price,
regular_price_currency=ci.product_price_currency,
)
def _attach_market_place_namespace(ci: CartItem) -> None:
"""Build a SimpleNamespace 'market_place' from denormalized columns."""
if ci.market_place_id:
ci.market_place = SimpleNamespace(
id=ci.market_place_id,
name=ci.market_place_name,
container_id=ci.market_place_container_id,
)
else:
ci.market_place = None
async def get_cart(session):
ident = current_cart_identity()
filters = [CartItem.deleted_at.is_(None)]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
else:
filters.append(CartItem.session_id == ident["session_id"])
result = await session.execute(
select(CartItem)
.where(*filters)
.order_by(CartItem.created_at.desc())
)
items = list(result.scalars().all())
for ci in items:
_attach_product_namespace(ci)
_attach_market_place_namespace(ci)
return items

View File

@@ -2,7 +2,7 @@
Page-scoped cart queries.
Groups cart items and calendar entries by their owning page (Post),
determined via CartItem.market_place.container_id and CalendarEntry.calendar.container_id
determined via CartItem.market_place_container_id
(where container_type == "page").
"""
from __future__ import annotations
@@ -12,24 +12,21 @@ from collections import defaultdict
from types import SimpleNamespace
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.market import CartItem
from shared.models.market_place import MarketPlace
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, PostDTO, dto_from_dict
from .identity import current_cart_identity
from .get_cart import _attach_product_namespace, _attach_market_place_namespace
async def get_cart_for_page(session, post_id: int) -> list[CartItem]:
"""Return cart items scoped to a specific page (via MarketPlace.container_id)."""
"""Return cart items scoped to a specific page (via denormalized market_place_container_id)."""
ident = current_cart_identity()
filters = [
CartItem.deleted_at.is_(None),
MarketPlace.container_type == "page",
MarketPlace.container_id == post_id,
MarketPlace.deleted_at.is_(None),
CartItem.market_place_container_id == post_id,
]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
@@ -38,15 +35,16 @@ async def get_cart_for_page(session, post_id: int) -> list[CartItem]:
result = await session.execute(
select(CartItem)
.join(MarketPlace, CartItem.market_place_id == MarketPlace.id)
.where(*filters)
.order_by(CartItem.created_at.desc())
.options(
selectinload(CartItem.product),
selectinload(CartItem.market_place),
)
)
return result.scalars().all()
items = list(result.scalars().all())
for ci in items:
_attach_product_namespace(ci)
_attach_market_place_namespace(ci)
return items
async def get_calendar_entries_for_page(session, post_id: int):

View File

@@ -4,10 +4,9 @@ from decimal import Decimal
def total(cart):
return sum(
(
Decimal(str(item.product.special_price or item.product.regular_price))
Decimal(str(item.product_special_price or item.product_regular_price))
* item.quantity
)
for item in cart
if (item.product.special_price or item.product.regular_price) is not None
if (item.product_special_price or item.product_regular_price) is not None
)

View File

@@ -83,7 +83,6 @@ def register() -> Blueprint:
# --- cart-items (product slugs + quantities for template rendering) ---
async def _cart_items():
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.market import CartItem
user_id = request.args.get("user_id", type=int)
@@ -98,13 +97,13 @@ def register() -> Blueprint:
return []
result = await g.s.execute(
select(CartItem).where(*filters).options(selectinload(CartItem.product))
select(CartItem).where(*filters)
)
items = result.scalars().all()
return [
{
"product_id": item.product_id,
"product_slug": item.product.slug if item.product else None,
"product_slug": item.product_slug,
"quantity": item.quantity,
}
for item in items

View File

@@ -0,0 +1 @@
from .routes import register as register_inbox

161
cart/bp/inbox/routes.py Normal file
View File

@@ -0,0 +1,161 @@
"""Cart internal inbox endpoint.
Receives AP-shaped activities from other services via HMAC-authenticated
POST to ``/internal/inbox``. Routes to handlers registered via the
internal inbox dispatch infrastructure.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from shared.infrastructure.internal_inbox import dispatch_internal_activity, register_internal_handler
from shared.infrastructure.internal_inbox_client import INBOX_HEADER
from shared.models.market import CartItem
log = logging.getLogger(__name__)
def register() -> Blueprint:
bp = Blueprint("inbox", __name__, url_prefix="/internal/inbox")
@bp.before_request
async def _require_inbox_header():
if not request.headers.get(INBOX_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
@bp.post("")
async def handle_inbox():
body = await request.get_json()
if not body:
return jsonify({"error": "empty body"}), 400
try:
result = await dispatch_internal_activity(g.s, body)
return jsonify(result)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
except Exception as exc:
log.exception("Internal inbox dispatch failed")
return jsonify({"error": str(exc)}), 500
# --- Handler: Add rose:CartItem ---
async def _handle_add_cart_item(session, body: dict) -> dict:
obj = body["object"]
user_id = obj.get("user_id")
session_id = obj.get("session_id")
product_id = obj["product_id"]
count = obj.get("quantity", 1)
market_place_id = obj.get("market_place_id")
# Look for existing cart item
filters = [
CartItem.deleted_at.is_(None),
CartItem.product_id == product_id,
]
if user_id is not None:
filters.append(CartItem.user_id == user_id)
else:
filters.append(CartItem.session_id == session_id)
existing = await session.scalar(select(CartItem).where(*filters))
if existing:
if count > 0:
existing.quantity = count
else:
existing.deleted_at = datetime.now(timezone.utc)
ci = existing
else:
if count <= 0:
return {"ok": True, "action": "noop"}
ci = CartItem(
user_id=user_id,
session_id=session_id,
product_id=product_id,
quantity=count,
market_place_id=market_place_id,
# Denormalized product data
product_title=obj.get("product_title"),
product_slug=obj.get("product_slug"),
product_image=obj.get("product_image"),
product_brand=obj.get("product_brand"),
product_regular_price=obj.get("product_regular_price"),
product_special_price=obj.get("product_special_price"),
product_price_currency=obj.get("product_price_currency"),
# Denormalized marketplace data
market_place_name=obj.get("market_place_name"),
market_place_container_id=obj.get("market_place_container_id"),
)
session.add(ci)
await session.flush()
return {
"ok": True,
"cart_item_id": ci.id,
"quantity": ci.quantity,
}
register_internal_handler("Add", "rose:CartItem", _handle_add_cart_item)
# --- Handler: Remove rose:CartItem ---
async def _handle_remove_cart_item(session, body: dict) -> dict:
obj = body["object"]
user_id = obj.get("user_id")
session_id = obj.get("session_id")
product_id = obj["product_id"]
filters = [
CartItem.deleted_at.is_(None),
CartItem.product_id == product_id,
]
if user_id is not None:
filters.append(CartItem.user_id == user_id)
else:
filters.append(CartItem.session_id == session_id)
existing = await session.scalar(select(CartItem).where(*filters))
if existing:
existing.deleted_at = datetime.now(timezone.utc)
await session.flush()
return {"ok": True}
register_internal_handler("Remove", "rose:CartItem", _handle_remove_cart_item)
# --- Handler: Update rose:CartItem ---
async def _handle_update_cart_item(session, body: dict) -> dict:
obj = body["object"]
user_id = obj.get("user_id")
session_id = obj.get("session_id")
product_id = obj["product_id"]
quantity = obj.get("quantity", 1)
filters = [
CartItem.deleted_at.is_(None),
CartItem.product_id == product_id,
]
if user_id is not None:
filters.append(CartItem.user_id == user_id)
else:
filters.append(CartItem.session_id == session_id)
existing = await session.scalar(select(CartItem).where(*filters))
if existing:
if quantity <= 0:
existing.deleted_at = datetime.now(timezone.utc)
else:
existing.quantity = quantity
await session.flush()
return {"ok": True, "quantity": existing.quantity}
return {"ok": True, "action": "noop"}
register_internal_handler("Update", "rose:CartItem", _handle_update_cart_item)
return bp

View File

@@ -5,7 +5,6 @@ from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload
from shared.models.market import Product
from shared.models.order import Order, OrderItem
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config
@@ -49,7 +48,7 @@ def register() -> Blueprint:
result = await g.s.execute(
select(Order)
.options(
selectinload(Order.items).selectinload(OrderItem.product)
selectinload(Order.items)
)
.where(Order.id == order_id, owner)
)

View File

@@ -5,7 +5,6 @@ from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload
from shared.models.market import Product
from shared.models.order import Order, OrderItem
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config
@@ -86,16 +85,11 @@ def register(url_prefix: str) -> Blueprint:
exists(
select(1)
.select_from(OrderItem)
.join(Product, Product.id == OrderItem.product_id)
.where(
OrderItem.order_id == Order.id,
or_(
OrderItem.product_title.ilike(term),
Product.title.ilike(term),
Product.description_short.ilike(term),
Product.description_html.ilike(term),
Product.slug.ilike(term),
Product.brand.ilike(term),
OrderItem.product_slug.ilike(term),
),
)
)

View File

@@ -7,13 +7,13 @@
<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) }}">
<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 and item.product.image %}
{% if item.product_image %}
<img
src="{{ item.product.image }}"
alt="{{ item.product_title or item.product.title or 'Product image' }}"
src="{{ item.product_image }}"
alt="{{ item.product_title or 'Product image' }}"
class="w-full h-full object-contain object-center"
loading="lazy"
decoding="async"
@@ -29,7 +29,7 @@
<div class="flex-1 flex justify-between gap-3">
<div>
<p class="font-medium">
{{ item.product_title or (item.product and item.product.title) or 'Unknown product' }}
{{ item.product_title or 'Unknown product' }}
</p>
<p class="text-[11px] text-stone-500">
Product ID: {{ item.product_id }}