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:
48
cart/alembic/versions/0002_denormalize_product_data.py
Normal file
48
cart/alembic/versions/0002_denormalize_product_data.py
Normal 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")
|
||||
@@ -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) ---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
1
cart/bp/inbox/__init__.py
Normal file
1
cart/bp/inbox/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .routes import register as register_inbox
|
||||
161
cart/bp/inbox/routes.py
Normal file
161
cart/bp/inbox/routes.py
Normal 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
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user