Split cart into 4 microservices: relations, likes, orders, page-config→blog
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Phase 1 - Relations service (internal): owns ContainerRelation, exposes get-children data + attach/detach-child actions. Retargeted events, blog, market callers from cart to relations. Phase 2 - Likes service (internal): unified Like model replaces ProductLike and PostLike with generic target_type/target_slug/target_id. Exposes is-liked, liked-slugs, liked-ids data + toggle action. Phase 3 - PageConfig → blog: moved ownership to blog with direct DB queries, removed proxy endpoints from cart. Phase 4 - Orders service (public): owns Order/OrderItem + SumUp checkout flow. Cart checkout now delegates to orders via create-order action. Webhook/return routes and reconciliation moved to orders. Phase 5 - Infrastructure: docker-compose, deploy.sh, Dockerfiles updated for all 3 new services. Added orders_url helper and factory model imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,12 @@ COPY federation/__init__.py ./federation/__init__.py
|
||||
COPY federation/models/ ./federation/models/
|
||||
COPY account/__init__.py ./account/__init__.py
|
||||
COPY account/models/ ./account/models/
|
||||
COPY relations/__init__.py ./relations/__init__.py
|
||||
COPY relations/models/ ./relations/models/
|
||||
COPY likes/__init__.py ./likes/__init__.py
|
||||
COPY likes/models/ ./likes/models/
|
||||
COPY orders/__init__.py ./orders/__init__.py
|
||||
COPY orders/models/ ./orders/models/
|
||||
|
||||
# ---------- Runtime setup ----------
|
||||
COPY market/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
@@ -9,7 +9,7 @@ MODELS = [
|
||||
TABLES = frozenset({
|
||||
"products", "product_images", "product_sections", "product_labels",
|
||||
"product_stickers", "product_attributes", "product_nutrition",
|
||||
"product_allergens", "product_likes",
|
||||
"product_allergens",
|
||||
"market_places", "nav_tops", "nav_subs",
|
||||
"listings", "listing_items",
|
||||
"link_errors", "link_externals", "subcategory_redirects", "product_logs",
|
||||
|
||||
@@ -12,9 +12,9 @@ from models.market import (
|
||||
Listing, ListingItem,
|
||||
NavTop, NavSub,
|
||||
ProductSticker, ProductLabel,
|
||||
ProductAttribute, ProductNutrition, ProductAllergen, ProductLike
|
||||
|
||||
ProductAttribute, ProductNutrition, ProductAllergen,
|
||||
)
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from sqlalchemy import func, case
|
||||
|
||||
|
||||
@@ -72,26 +72,8 @@ async def db_nav(session, market_id=None) -> Dict:
|
||||
|
||||
async def db_product_full(session, slug: str, user_id=0) -> Optional[dict]:
|
||||
|
||||
liked_product_ids_subq = (
|
||||
select(ProductLike.product_slug)
|
||||
.where(
|
||||
and_(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
is_liked_case = case(
|
||||
(and_(
|
||||
(Product.slug.in_(liked_product_ids_subq)),
|
||||
Product.deleted_at.is_(None)
|
||||
), True),
|
||||
else_=False
|
||||
).label("is_liked")
|
||||
|
||||
q = (
|
||||
select(Product, is_liked_case)
|
||||
select(Product)
|
||||
.where(Product.slug == slug, Product.deleted_at.is_(None))
|
||||
.options(
|
||||
selectinload(Product.images.and_(ProductImage.deleted_at.is_(None))),
|
||||
@@ -105,11 +87,17 @@ async def db_product_full(session, slug: str, user_id=0) -> Optional[dict]:
|
||||
)
|
||||
result = await session.execute(q)
|
||||
|
||||
row = result.first() if result is not None else None
|
||||
p, is_liked = row if row else (None, None)
|
||||
p = result.scalars().first()
|
||||
if not p:
|
||||
return None
|
||||
|
||||
is_liked = False
|
||||
if user_id:
|
||||
liked_data = await fetch_data("likes", "is-liked", params={
|
||||
"user_id": user_id, "target_type": "product", "target_slug": slug,
|
||||
}, required=False)
|
||||
is_liked = (liked_data or {}).get("liked", False)
|
||||
|
||||
gallery = [
|
||||
img.url
|
||||
for img in sorted(p.images, key=lambda i: (i.kind or "gallery", i.position or 0))
|
||||
@@ -170,26 +158,9 @@ async def db_product_full(session, slug: str, user_id=0) -> Optional[dict]:
|
||||
|
||||
|
||||
async def db_product_full_id(session, id:int, user_id=0) -> Optional[dict]:
|
||||
liked_product_ids_subq = (
|
||||
select(ProductLike.product_slug)
|
||||
.where(
|
||||
and_(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
is_liked_case = case(
|
||||
(
|
||||
(Product.slug.in_(liked_product_ids_subq)),
|
||||
True
|
||||
),
|
||||
else_=False
|
||||
).label("is_liked")
|
||||
|
||||
q = (
|
||||
select(Product, is_liked_case)
|
||||
select(Product)
|
||||
.where(Product.id == id)
|
||||
.options(
|
||||
selectinload(Product.images.and_(ProductImage.deleted_at.is_(None))),
|
||||
@@ -203,11 +174,17 @@ async def db_product_full_id(session, id:int, user_id=0) -> Optional[dict]:
|
||||
)
|
||||
result = await session.execute(q)
|
||||
|
||||
row = result.first() if result is not None else None
|
||||
p, is_liked = row if row else (None, None)
|
||||
p = result.scalars().first()
|
||||
if not p:
|
||||
return None
|
||||
|
||||
is_liked = False
|
||||
if user_id:
|
||||
liked_data = await fetch_data("likes", "is-liked", params={
|
||||
"user_id": user_id, "target_type": "product", "target_slug": p.slug,
|
||||
}, required=False)
|
||||
is_liked = (liked_data or {}).get("liked", False)
|
||||
|
||||
gallery = [
|
||||
img.url
|
||||
for img in sorted(p.images, key=lambda i: (i.kind or "gallery", i.position or 0))
|
||||
@@ -367,40 +344,25 @@ async def db_products_nocounts(
|
||||
)
|
||||
if search_q:
|
||||
filter_conditions.append(func.lower(Product.description_short).contains(search_q))
|
||||
# Fetch liked slugs from likes service (once)
|
||||
liked_slugs_set: set[str] = set()
|
||||
if user_id and (liked or True):
|
||||
liked_slugs_list = await fetch_data("likes", "liked-slugs", params={
|
||||
"user_id": user_id, "target_type": "product",
|
||||
}, required=False) or []
|
||||
liked_slugs_set = set(liked_slugs_list)
|
||||
|
||||
if liked:
|
||||
liked_subq = liked_subq = (
|
||||
select(ProductLike.product_slug)
|
||||
.where(
|
||||
and_(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
filter_conditions.append(Product.slug.in_(liked_subq))
|
||||
|
||||
if not liked_slugs_set:
|
||||
return {"total_pages": 1, "items": []}
|
||||
filter_conditions.append(Product.slug.in_(liked_slugs_set))
|
||||
|
||||
filtered_count_query = select(func.count(Product.id)).where(Product.id.in_(base_ids), *filter_conditions)
|
||||
total_filtered = (await session.execute(filtered_count_query)).scalars().one()
|
||||
total_pages = max(1, (total_filtered + page_size - 1) // page_size)
|
||||
page = max(1, page)
|
||||
|
||||
|
||||
liked_product_slugs_subq = (
|
||||
select(ProductLike.product_slug)
|
||||
.where(
|
||||
and_(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
is_liked_case = case(
|
||||
(Product.slug.in_(liked_product_slugs_subq), True),
|
||||
else_=False
|
||||
).label("is_liked")
|
||||
|
||||
q_filtered = select(Product, is_liked_case).where(Product.id.in_(base_ids), *filter_conditions).options(
|
||||
q_filtered = select(Product).where(Product.id.in_(base_ids), *filter_conditions).options(
|
||||
selectinload(Product.images),
|
||||
selectinload(Product.sections),
|
||||
selectinload(Product.labels),
|
||||
@@ -434,10 +396,11 @@ async def db_products_nocounts(
|
||||
|
||||
offset_val = (page - 1) * page_size
|
||||
q_filtered = q_filtered.offset(offset_val).limit(page_size)
|
||||
products_page = (await session.execute(q_filtered)).all()
|
||||
products_page = (await session.execute(q_filtered)).scalars().all()
|
||||
|
||||
items: List[Dict] = []
|
||||
for p, is_liked in products_page:
|
||||
for p in products_page:
|
||||
is_liked = p.slug in liked_slugs_set
|
||||
gallery_imgs = sorted((img for img in p.images), key=lambda i: (i.kind or "gallery", i.position or 0))
|
||||
gallery = [img.url for img in gallery_imgs if (img.kind or "gallery") == "gallery"]
|
||||
embedded = [img.url for img in sorted(p.images, key=lambda i: i.position or 0) if (img.kind or "") == "embedded"]
|
||||
@@ -580,30 +543,14 @@ async def db_products_counts(
|
||||
labels_list: List[Dict] = []
|
||||
liked_count = 0
|
||||
search_count = 0
|
||||
liked_product_slugs_subq = (
|
||||
select(ProductLike.product_slug)
|
||||
.where(ProductLike.user_id == user_id, ProductLike.deleted_at.is_(None))
|
||||
)
|
||||
liked_count = await session.scalar(
|
||||
select(func.count(Product.id))
|
||||
.where(
|
||||
Product.id.in_(base_ids),
|
||||
Product.slug.in_(liked_product_slugs_subq),
|
||||
Product.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
liked_count = (await session.execute(
|
||||
select(func.count())
|
||||
.select_from(ProductLike)
|
||||
.where(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.product_slug.in_(
|
||||
select(Product.slug).where(Product.id.in_(base_ids))
|
||||
),
|
||||
ProductLike.deleted_at.is_(None)
|
||||
)
|
||||
)).scalar_one() if user_id else 0
|
||||
if user_id:
|
||||
liked_slugs_list = await fetch_data("likes", "liked-slugs", params={
|
||||
"user_id": user_id, "target_type": "product",
|
||||
}, required=False) or []
|
||||
liked_slugs_in_base = set(liked_slugs_list) & set(base_products_slugs)
|
||||
liked_count = len(liked_slugs_in_base)
|
||||
else:
|
||||
liked_count = 0
|
||||
|
||||
# Brand counts
|
||||
brand_count_rows = await session.execute(
|
||||
|
||||
@@ -13,8 +13,7 @@ from .blacklist.product_details import is_blacklisted_heading
|
||||
from shared.utils import host_url
|
||||
|
||||
|
||||
from sqlalchemy import select
|
||||
from models import ProductLike
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from ...market.filters.qs import decode
|
||||
|
||||
|
||||
@@ -171,15 +170,9 @@ async def _is_liked(user_id: int | None, slug: str) -> bool:
|
||||
"""
|
||||
if not user_id:
|
||||
return False
|
||||
# because ProductLike has composite PK (user_id, product_slug),
|
||||
# we can fetch it by primary key dict:
|
||||
row = await g.s.execute(
|
||||
select(ProductLike).where(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.product_slug == slug,
|
||||
)
|
||||
)
|
||||
row.scalar_one_or_none()
|
||||
return row is not None
|
||||
liked_data = await fetch_data("likes", "is-liked", params={
|
||||
"user_id": user_id, "target_type": "product", "target_slug": slug,
|
||||
}, required=False)
|
||||
return (liked_data or {}).get("liked", False)
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from quart import (
|
||||
)
|
||||
from sqlalchemy import select, func, update
|
||||
|
||||
from models.market import Product, ProductLike
|
||||
from models.market import Product
|
||||
from ..browse.services.slugs import canonical_html_slug
|
||||
from ..browse.services.blacklist.product import is_product_blocked
|
||||
from ..browse.services import db_backend as cb
|
||||
@@ -18,7 +18,8 @@ from ..browse.services import _massage_product
|
||||
from shared.utils import host_url
|
||||
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||
from ..cart.services import total
|
||||
from .services.product_operations import toggle_product_like, massage_full_product
|
||||
from shared.infrastructure.actions import call_action
|
||||
from .services.product_operations import massage_full_product
|
||||
|
||||
|
||||
def register():
|
||||
@@ -132,11 +133,10 @@ def register():
|
||||
|
||||
user_id = g.user.id
|
||||
|
||||
liked, error = await toggle_product_like(g.s, user_id, product_slug)
|
||||
|
||||
if error:
|
||||
resp = make_response(error, 404)
|
||||
return resp
|
||||
result = await call_action("likes", "toggle", payload={
|
||||
"user_id": user_id, "target_type": "product", "target_slug": product_slug,
|
||||
})
|
||||
liked = result["liked"]
|
||||
|
||||
html = await render_template(
|
||||
"_types/browse/like/button.html",
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.market import Product, ProductLike
|
||||
from models.market import Product
|
||||
|
||||
|
||||
def massage_full_product(product: Product) -> dict:
|
||||
@@ -44,52 +39,3 @@ def massage_full_product(product: Product) -> dict:
|
||||
return _massage_product(d)
|
||||
|
||||
|
||||
async def toggle_product_like(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
product_slug: str,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Toggle a product like for a given user using soft deletes.
|
||||
Returns (liked_state, error_message).
|
||||
- If error_message is not None, an error occurred.
|
||||
- liked_state indicates whether product is now liked (True) or unliked (False).
|
||||
"""
|
||||
from sqlalchemy import func, update
|
||||
|
||||
# Get product_id from slug
|
||||
product_id = await session.scalar(
|
||||
select(Product.id).where(Product.slug == product_slug, Product.deleted_at.is_(None))
|
||||
)
|
||||
if not product_id:
|
||||
return False, "Product not found"
|
||||
|
||||
# Check if like exists (not deleted)
|
||||
existing = await session.scalar(
|
||||
select(ProductLike).where(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.product_slug == product_slug,
|
||||
ProductLike.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Unlike: soft delete the like
|
||||
await session.execute(
|
||||
update(ProductLike)
|
||||
.where(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.product_slug == product_slug,
|
||||
ProductLike.deleted_at.is_(None),
|
||||
)
|
||||
.values(deleted_at=func.now())
|
||||
)
|
||||
return False, None
|
||||
else:
|
||||
# Like: add a new like
|
||||
new_like = ProductLike(
|
||||
user_id=user_id,
|
||||
product_slug=product_slug,
|
||||
)
|
||||
session.add(new_like)
|
||||
return True, None
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from .market import (
|
||||
Product, ProductLike, ProductImage, ProductSection,
|
||||
Product, ProductImage, ProductSection,
|
||||
NavTop, NavSub, Listing, ListingItem,
|
||||
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
|
||||
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from shared.models.market import ( # noqa: F401
|
||||
Product, ProductLike, ProductImage, ProductSection,
|
||||
Product, ProductImage, ProductSection,
|
||||
NavTop, NavSub, Listing, ListingItem,
|
||||
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
|
||||
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
|
||||
|
||||
Reference in New Issue
Block a user