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/") @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