All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m44s
Replace ~250 render_to_sx calls across all services with sync sx_call, converting many async functions to sync where no other awaits remained. Make render_to_sx/render_to_sx_with_env private (_render_to_sx). Add (post-header-ctx) IO primitive and shared post/post-admin defmacros. Convert built-in post/post-admin layouts from Python to register_sx_layout with .sx defcomps. Remove dead post_admin_mobile_nav_sx. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
269 lines
9.7 KiB
Python
269 lines
9.7 KiB
Python
from __future__ import annotations
|
|
|
|
from quart import (
|
|
g,
|
|
Blueprint,
|
|
abort,
|
|
redirect,
|
|
make_response,
|
|
)
|
|
from sqlalchemy import select, func, update
|
|
|
|
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
|
|
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 shared.infrastructure.actions import call_action
|
|
from .services.product_operations import massage_full_product
|
|
from shared.sx.helpers import sx_response
|
|
|
|
|
|
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
|
|
|
|
from shared.sx.page import get_template_context
|
|
from sxc.pages.renders import render_product_page, render_product_oob
|
|
|
|
tctx = await get_template_context()
|
|
item_data = getattr(g, "item_data", {})
|
|
d = item_data.get("d", {})
|
|
tctx["liked_by_current_user"] = item_data.get("liked", False)
|
|
if not is_htmx_request():
|
|
html = await render_product_page(tctx, d)
|
|
return html
|
|
else:
|
|
sx_src = await render_product_oob(tctx, d)
|
|
return sx_response(sx_src)
|
|
|
|
@bp.post("/like/toggle/")
|
|
@clear_cache(tag="browse", tag_scope="user")
|
|
async def like_toggle():
|
|
product_slug = g.product_slug
|
|
|
|
from sxc.pages.renders import render_like_toggle_button
|
|
|
|
if not g.user:
|
|
return sx_response(render_like_toggle_button(product_slug, False), status=403)
|
|
|
|
user_id = g.user.id
|
|
|
|
result = await call_action("likes", "toggle", payload={
|
|
"user_id": user_id, "target_type": "product", "target_slug": product_slug,
|
|
})
|
|
liked = result["liked"]
|
|
|
|
return sx_response(render_like_toggle_button(product_slug, liked))
|
|
|
|
|
|
|
|
@bp.get("/admin/")
|
|
async def admin():
|
|
from shared.browser.app.utils.htmx import is_htmx_request
|
|
|
|
from shared.sx.page import get_template_context
|
|
from sxc.pages.renders import render_product_admin_page, render_product_admin_oob
|
|
|
|
tctx = await get_template_context()
|
|
item_data = getattr(g, "item_data", {})
|
|
d = item_data.get("d", {})
|
|
tctx["liked_by_current_user"] = item_data.get("liked", False)
|
|
if not is_htmx_request():
|
|
html = await render_product_admin_page(tctx, d)
|
|
return await make_response(html)
|
|
else:
|
|
sx_src = await render_product_admin_oob(tctx, d)
|
|
return sx_response(sx_src)
|
|
|
|
|
|
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("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
|
|
from sxc.pages.renders import render_cart_added_response
|
|
item_data = getattr(g, "item_data", {})
|
|
d = item_data.get("d", {})
|
|
return sx_response(render_cart_added_response(g.cart, ci_ns, d))
|
|
|
|
# normal POST: go to cart page
|
|
from shared.infrastructure.urls import cart_url
|
|
return redirect(cart_url("/"))
|
|
|
|
|
|
|
|
return bp
|