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

@@ -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
)