Files
mono/shared/services/cart_impl.py
giles e65bd41ebe Decouple per-service Alembic migrations and fix cross-DB queries
Each service (blog, market, cart, events, federation, account) now owns
its own database schema with independent Alembic migrations. Removes the
monolithic shared/alembic/ that ran all migrations against a single DB.

- Add per-service alembic.ini, env.py, and 0001_initial.py migrations
- Add shared/db/alembic_env.py helper with table-name filtering
- Fix cross-DB FK in blog/models/snippet.py (users lives in db_account)
- Fix cart_impl.py cross-DB queries: fetch products and market_places
  via internal data endpoints instead of direct SQL joins
- Fix blog ghost_sync to fetch page_configs from cart via data endpoint
- Add products-by-ids and page-config-ensure data endpoints
- Update all entrypoint.sh to create own DB and run own migrations
- Cart now uses db_cart instead of db_market
- Add docker-compose.dev.yml, dev.sh for local development
- CI deploys both rose-ash swarm stack and rose-ash-dev compose stack
- Fix Quart namespace package crash (root_path in factory.py)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:07:24 +00:00

183 lines
7.0 KiB
Python

"""SQL-backed CartService implementation.
Queries ``shared.models.market.CartItem`` — only this module may write
to cart-domain tables on behalf of other domains.
"""
from __future__ import annotations
from decimal import Decimal
from sqlalchemy import select, update, func
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.market import CartItem
from shared.contracts.dtos import CartItemDTO, CartSummaryDTO
def _item_to_dto(ci: CartItem, product: dict | None) -> CartItemDTO:
return CartItemDTO(
id=ci.id,
product_id=ci.product_id,
quantity=ci.quantity,
product_title=product["title"] if product else None,
product_slug=product["slug"] if product else None,
product_image=product["image"] if product else None,
unit_price=Decimal(str(product.get("special_price") or product.get("regular_price") or 0)) if product else None,
market_place_id=ci.market_place_id,
)
async def _fetch_products_map(fetch_data, product_ids: list[int]) -> dict[int, dict]:
"""Fetch product details from market service, return {id: product_dict}."""
if not product_ids:
return {}
raw = await fetch_data("market", "products-by-ids",
params={"ids": ",".join(str(i) for i in product_ids)},
required=False) or []
return {p["id"]: p for p in raw}
class SqlCartService:
async def cart_summary(
self, session: AsyncSession, *,
user_id: int | None, session_id: str | None,
page_slug: str | None = None,
) -> CartSummaryDTO:
"""Build a lightweight cart summary for the current identity."""
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict
# Resolve page filter via blog data endpoint
page_post_id: int | None = None
if page_slug:
post = await fetch_data("blog", "post-by-slug",
params={"slug": page_slug}, required=False)
if post and post.get("is_page"):
page_post_id = post["id"]
# --- product cart ---
cart_q = select(CartItem).where(CartItem.deleted_at.is_(None))
if user_id is not None:
cart_q = cart_q.where(CartItem.user_id == user_id)
elif session_id is not None:
cart_q = cart_q.where(CartItem.session_id == session_id)
else:
return CartSummaryDTO()
if page_post_id is not None:
mps = await fetch_data("market", "marketplaces-for-container",
params={"type": "page", "id": page_post_id},
required=False) or []
mp_ids = [mp["id"] for mp in mps]
if mp_ids:
cart_q = cart_q.where(CartItem.market_place_id.in_(mp_ids))
else:
return CartSummaryDTO()
result = await session.execute(cart_q)
cart_items = result.scalars().all()
products = await _fetch_products_map(
fetch_data, list({ci.product_id for ci in cart_items}),
)
count = sum(ci.quantity for ci in cart_items)
total = Decimal("0")
for ci in cart_items:
p = products.get(ci.product_id)
if p:
price = p.get("special_price") or p.get("regular_price")
if price:
total += Decimal(str(price)) * ci.quantity
# --- calendar entries via events data endpoint ---
cal_params: dict = {}
if user_id is not None:
cal_params["user_id"] = user_id
if session_id is not None:
cal_params["session_id"] = session_id
if page_post_id is not None:
cal_params["page_id"] = page_post_id
raw_entries = await fetch_data("events", "entries-for-page",
params=cal_params, required=False) or []
else:
raw_entries = await fetch_data("events", "pending-entries",
params=cal_params, required=False) or []
cal_entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw_entries]
calendar_count = len(cal_entries)
calendar_total = sum(Decimal(str(e.cost or 0)) for e in cal_entries if e.cost is not None)
# --- tickets via events data endpoint ---
if page_post_id is not None:
raw_tickets = await fetch_data("events", "tickets-for-page",
params=cal_params, required=False) or []
else:
tk_params = {k: v for k, v in cal_params.items() if k != "page_id"}
raw_tickets = await fetch_data("events", "pending-tickets",
params=tk_params, required=False) or []
tickets = [dto_from_dict(TicketDTO, t) for t in raw_tickets]
ticket_count = len(tickets)
ticket_total = sum(Decimal(str(t.price or 0)) for t in tickets)
items = [_item_to_dto(ci, products.get(ci.product_id)) for ci in cart_items]
return CartSummaryDTO(
count=count,
total=total,
calendar_count=calendar_count,
calendar_total=calendar_total,
items=items,
ticket_count=ticket_count,
ticket_total=ticket_total,
)
async def cart_items(
self, session: AsyncSession, *,
user_id: int | None, session_id: str | None,
) -> list[CartItemDTO]:
from shared.infrastructure.data_client import fetch_data
cart_q = select(CartItem).where(CartItem.deleted_at.is_(None))
if user_id is not None:
cart_q = cart_q.where(CartItem.user_id == user_id)
elif session_id is not None:
cart_q = cart_q.where(CartItem.session_id == session_id)
else:
return []
cart_q = cart_q.order_by(CartItem.created_at.desc())
result = await session.execute(cart_q)
items = result.scalars().all()
products = await _fetch_products_map(
fetch_data, list({ci.product_id for ci in items}),
)
return [_item_to_dto(ci, products.get(ci.product_id)) for ci in items]
async def adopt_cart_for_user(
self, session: AsyncSession, user_id: int, session_id: str,
) -> None:
"""Adopt anonymous cart items for a logged-in user."""
anon_result = await session.execute(
select(CartItem).where(
CartItem.deleted_at.is_(None),
CartItem.user_id.is_(None),
CartItem.session_id == session_id,
)
)
anon_items = anon_result.scalars().all()
if anon_items:
# Soft-delete existing user cart
await session.execute(
update(CartItem)
.where(CartItem.deleted_at.is_(None), CartItem.user_id == user_id)
.values(deleted_at=func.now())
)
for ci in anon_items:
ci.user_id = user_id