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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user