Files
mono/market/bp/product/routes.py
giles 81112c716b 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>
2026-02-26 14:49:04 +00:00

270 lines
9.3 KiB
Python

from __future__ import annotations
from quart import (
g,
Blueprint,
abort,
redirect,
render_template,
make_response,
)
from sqlalchemy import select, func, update
from models.market import Product, ProductLike
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
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
def register():
bp = Blueprint("product", __name__, url_prefix="/product/<product_slug>")
@bp.url_value_preprocessor
def pull_product_slug(endpoint, values):
# product_slug is distinct from the app-level "slug"/"page_slug" params,
# so it won't be popped by the app-level preprocessor in app.py.
g.product_slug = values.pop("product_slug", None)
# ─────────────────────────────────────────────────────────────
# BEFORE REQUEST: Slug or numeric ID resolver
# ─────────────────────────────────────────────────────────────
@bp.before_request
async def resolve_product():
from quart import request as req
raw_slug = g.product_slug = getattr(g, "product_slug", None)
if raw_slug is None:
return
is_post = req.method == "POST"
# 1. If slug is INT → load product by ID
if raw_slug.isdigit():
product_id = int(raw_slug)
product = await cb.db_product_full_id(
g.s, product_id, user_id=g.user.id if g.user else 0
)
if not product:
abort(404)
# If product is deleted → SHOW as-is
if product["deleted_at"]:
d = product
g.item_data = {"d": d, "slug": product["slug"], "liked": False}
return
# Not deleted → redirect to canonical slug (GET only)
if not is_post:
canon = canonical_html_slug(product["slug"])
return redirect(
host_url(url_for("market.browse.product.product_detail", product_slug=canon))
)
g.item_data = {"d": product, "slug": product["slug"], "liked": False}
return
# 2. Normal slug-based behaviour
if is_product_blocked(raw_slug):
abort(404)
canon = canonical_html_slug(raw_slug)
if canon != raw_slug and not is_post:
return redirect(
host_url(url_for("market.browse.product.product_detail", product_slug=canon))
)
# hydrate full product
d = await cb.db_product_full(
g.s, canon, user_id=g.user.id if g.user else 0
)
if not d:
abort(404)
g.item_data = {"d": d, "slug": canon, "liked": d.get("is_liked", False)}
@bp.context_processor
def context():
item_data = getattr(g, "item_data", None)
if item_data:
return {
**item_data,
}
else:
return {}
# ─────────────────────────────────────────────────────────────
# RENDER PRODUCT
# ─────────────────────────────────────────────────────────────
@bp.get("/")
@cache_page(tag="browse")
async def product_detail():
from shared.browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/product/index.html")
else:
# HTMX request: main panel + OOB elements
html = await render_template("_types/product/_oob_elements.html")
return html
@bp.post("/like/toggle/")
@clear_cache(tag="browse", tag_scope="user")
async def like_toggle():
product_slug = g.product_slug
if not g.user:
html = await render_template(
"_types/browse/like/button.html",
slug=product_slug,
liked=False,
)
resp = make_response(html, 403)
return resp
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
html = await render_template(
"_types/browse/like/button.html",
slug=product_slug,
liked=liked,
)
return html
@bp.get("/admin/")
async def admin():
from shared.browser.app.utils.htmx import is_htmx_request
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/product/admin/index.html")
else:
# HTMX request: main panel + OOB elements
html = await render_template("_types/product/admin/_oob_elements.html")
return await make_response(html)
from bp.cart.services.identity import current_cart_identity
from quart import request, url_for
from shared.infrastructure.internal_inbox_client import send_internal_activity
from shared.infrastructure.data_client import fetch_data
@bp.post("/cart/")
@clear_cache(tag="browse", tag_scope="user")
async def cart():
slug = g.product_slug
# Load product from local db_market
product = await g.s.scalar(
select(Product).where(
Product.slug == slug,
Product.deleted_at.is_(None),
)
)
if not product:
return await make_response("Product not found", 404)
# Read `count` from body (JSON or form), default to 1
count = 1
try:
if request.is_json:
data = await request.get_json()
if data is not None and "count" in data:
count = int(data["count"])
else:
form = await request.form
if "count" in form:
count = int(form["count"])
except (ValueError, TypeError):
count = 1
ident = current_cart_identity()
market = getattr(g, "market", None)
# Build AP activity with denormalized product data
activity_type = "Add" if count > 0 else "Remove"
activity = {
"type": activity_type,
"object": {
"type": "rose:CartItem",
"user_id": ident["user_id"],
"session_id": ident["session_id"],
"product_id": product.id,
"quantity": count,
"market_place_id": market.id if market else None,
# Denormalized product data
"product_title": product.title,
"product_slug": product.slug,
"product_image": product.image,
"product_brand": product.brand,
"product_regular_price": str(product.regular_price) if product.regular_price is not None else None,
"product_special_price": str(product.special_price) if product.special_price is not None else None,
"product_price_currency": product.regular_price_currency,
# Denormalized marketplace data
"market_place_name": market.name if market else None,
"market_place_container_id": market.container_id if market else None,
},
}
await send_internal_activity("cart", activity)
# Fetch updated cart items from cart service for template rendering
raw_cart = await fetch_data(
"cart", "cart-items",
params={
k: v for k, v in {
"user_id": ident["user_id"],
"session_id": ident["session_id"],
}.items() if v is not None
},
required=False,
) or []
# Build minimal cart list for template (product slug + quantity)
from types import SimpleNamespace
g.cart = [
SimpleNamespace(
product_id=ci["product_id"],
product=SimpleNamespace(slug=ci["product_slug"]),
quantity=ci["quantity"],
)
for ci in raw_cart
]
ci_ns = next(
(item for item in g.cart if item.product_id == product.id),
None,
)
# htmx response: OOB-swap mini cart + product buttons
if request.headers.get("HX-Request") == "true":
return await render_template(
"_types/product/_added.html",
cart=g.cart,
item=ci_ns,
)
# normal POST: go to cart page
from shared.infrastructure.urls import cart_url
return redirect(cart_url("/"))
return bp