"""MercadoLibre external marketplace REST endpoints. Routes: Config GET /pos/api/marketplace-ext/config POST /pos/api/marketplace-ext/connect DELETE /pos/api/marketplace-ext/connect GET /pos/api/marketplace-ext/categories Listings GET /pos/api/marketplace-ext/listings POST /pos/api/marketplace-ext/listings POST /pos/api/marketplace-ext/listings//sync POST /pos/api/marketplace-ext/listings//pause POST /pos/api/marketplace-ext/listings//activate DELETE /pos/api/marketplace-ext/listings/ Orders GET /pos/api/marketplace-ext/orders GET /pos/api/marketplace-ext/orders/ POST /pos/api/marketplace-ext/orders//convert Webhook (public) POST /pos/api/marketplace-ext/webhook/meli """ from flask import Blueprint, request, jsonify, g from middleware import require_auth, has_permission from tenant_db import get_tenant_conn, get_master_conn from services import marketplace_external_service as meli_svc def _get_public_base_url() -> str: """Build the tenant's public base URL from request headers (handles reverse proxy).""" proto = request.headers.get("X-Forwarded-Proto", request.scheme) host = request.headers.get("X-Forwarded-Host", request.host) # Cloudflare specific header cf_visitor = request.headers.get("CF-Visitor") if cf_visitor and '"scheme":"https"' in cf_visitor: proto = "https" # Force https for production domain if we detect http behind a TLS terminator if proto == "http" and ("nexusautoparts.com.mx" in host or request.headers.get("X-Forwarded-Ssl") == "on"): proto = "https" return f"{proto}://{host}/" from services.meli_service import MeliService, MeliAuthError marketplace_ext_bp = Blueprint( "marketplace_ext", __name__, url_prefix="/pos/api/marketplace-ext" ) # ─── Helpers ─────────────────────────────────────────────────────────────── def _require_meli_manage(): if not has_permission("marketplace.manage"): return jsonify({"error": "Missing permission: marketplace.manage"}), 403 return None # ═══════════════════════════════════════════════════════════════════════════ # CONFIG # ═══════════════════════════════════════════════════════════════════════════ @marketplace_ext_bp.route("/config", methods=["GET"]) @require_auth() def get_config(): conn = get_tenant_conn(g.tenant_id) try: cfg = meli_svc.get_meli_config(conn) # Never return tokens to frontend safe = { k: v for k, v in cfg.items() if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret") } safe["connected"] = bool(cfg.get("meli_access_token")) return jsonify(safe) finally: conn.close() @marketplace_ext_bp.route("/connect", methods=["POST"]) @require_auth() def connect_meli(): err = _require_meli_manage() if err: return err data = request.get_json() or {} code = data.get("code") client_id = data.get("client_id") client_secret = data.get("client_secret") redirect_uri = data.get("redirect_uri", "") if not code or not client_id or not client_secret: return jsonify({"error": "code, client_id and client_secret required"}), 400 try: token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri) except MeliAuthError as e: return jsonify({"error": str(e)}), 400 access_token = token_data.get("access_token") refresh_token = token_data.get("refresh_token") user_id = token_data.get("user_id") # Validate token by fetching user svc = MeliService(access_token) try: user = svc.get_user() except MeliAuthError as e: return jsonify({"error": f"Invalid token: {e}"}), 400 conn = get_tenant_conn(g.tenant_id) try: meli_svc.save_meli_config(conn, { "meli_access_token": access_token, "meli_refresh_token": refresh_token, "meli_user_id": str(user_id or user.get("id")), "meli_site_id": user.get("site_id", "MLM"), "meli_enabled": "true", "meli_client_id": client_id, "meli_client_secret": client_secret, }) return jsonify({ "ok": True, "user_id": user_id or user.get("id"), "nickname": user.get("nickname"), "site_id": user.get("site_id"), }) finally: conn.close() @marketplace_ext_bp.route("/connect", methods=["DELETE"]) @require_auth() def disconnect_meli(): err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: meli_svc.delete_meli_config(conn) return jsonify({"ok": True}) finally: conn.close() @marketplace_ext_bp.route("/categories", methods=["GET"]) @require_auth() def search_categories(): q = request.args.get("q", "") site_id = request.args.get("site_id", "MLM") if not q or len(q) < 2: return jsonify({"categories": []}) conn = get_tenant_conn(g.tenant_id) try: cfg = meli_svc.get_meli_config(conn) svc = meli_svc._get_meli_service(cfg) if not svc: return jsonify({"error": "MercadoLibre not connected"}), 400 result = svc.search_categories(site_id, q) return jsonify({"categories": result}) except MeliAuthError: return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() # ═══════════════════════════════════════════════════════════════════════════ # LISTINGS # ═══════════════════════════════════════════════════════════════════════════ @marketplace_ext_bp.route("/listings", methods=["GET"]) @require_auth() def list_listings(): page = int(request.args.get("page", 1)) per_page = min(int(request.args.get("per_page", 50)), 200) status = request.args.get("status") conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status) return jsonify(result) except MeliAuthError: return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings", methods=["POST"]) @require_auth() def create_listings(): err = _require_meli_manage() if err: return err data = request.get_json() or {} inventory_ids = data.get("inventory_ids", []) category_id = data.get("category_id") listing_type = data.get("listing_type", "gold_special") shipping_mode = data.get("shipping_mode", "me2") custom_data = data.get("custom_data", {}) if not inventory_ids: return jsonify({"error": "inventory_ids required"}), 400 if not category_id: return jsonify({"error": "category_id required"}), 400 conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.publish_items( conn, inventory_ids=inventory_ids, meli_category_id=category_id, listing_type_id=listing_type, shipping_mode=shipping_mode, custom_data=custom_data, base_url=_get_public_base_url(), ) return jsonify(result), 201 except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/inventory-check", methods=["POST"]) @require_auth() def inventory_check(): """Check local pre-flight status for ML publishing (duplicates, stock, price, image).""" data = request.get_json() or {} inventory_ids = data.get("inventory_ids", []) if not inventory_ids: return jsonify({"error": "inventory_ids required"}), 400 conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.check_inventory_ml_status(conn, inventory_ids, base_url=_get_public_base_url()) return jsonify(result) except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/categories//attributes", methods=["GET"]) @require_auth() def category_attributes(category_id): """Get required attributes for a MercadoLibre category.""" conn = get_tenant_conn(g.tenant_id) try: cfg = meli_svc.get_meli_config(conn) svc = meli_svc._get_meli_service(cfg) if not svc: return jsonify({"error": "MercadoLibre not connected"}), 400 attrs = svc.get_category_attributes(category_id) # Filter to required attributes only for the UI required = [a for a in attrs if a.get("tags", {}).get("required")] return jsonify({"attributes": required, "all": attrs}) except MeliAuthError: return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings/validate", methods=["POST"]) @require_auth() def validate_listings(): """Validate items payload against ML /items/validate without creating them.""" err = _require_meli_manage() if err: return err data = request.get_json() or {} inventory_ids = data.get("inventory_ids", []) category_id = data.get("category_id") listing_type = data.get("listing_type", "gold_special") shipping_mode = data.get("shipping_mode", "me2") custom_data = data.get("custom_data", {}) if not inventory_ids: return jsonify({"error": "inventory_ids required"}), 400 if not category_id: return jsonify({"error": "category_id required"}), 400 conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.validate_items( conn, inventory_ids=inventory_ids, meli_category_id=category_id, listing_type_id=listing_type, shipping_mode=shipping_mode, custom_data=custom_data, base_url=_get_public_base_url(), ) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings/async", methods=["POST"]) @require_auth() def create_listings_async(): """Enqueue ML publishing as a Celery background task.""" err = _require_meli_manage() if err: return err data = request.get_json() or {} inventory_ids = data.get("inventory_ids", []) category_id = data.get("category_id") listing_type = data.get("listing_type", "gold_special") shipping_mode = data.get("shipping_mode", "me2") custom_data = data.get("custom_data", {}) if not inventory_ids: return jsonify({"error": "inventory_ids required"}), 400 if not category_id: return jsonify({"error": "category_id required"}), 400 try: from tasks import publish_meli_items_task task = publish_meli_items_task.delay( g.tenant_id, inventory_ids=inventory_ids, category_id=category_id, listing_type=listing_type, shipping_mode=shipping_mode, custom_data=custom_data, base_url=_get_public_base_url(), ) return jsonify({"task_id": task.id, "status": "queued"}), 202 except Exception as e: return jsonify({"error": str(e)}), 500 @marketplace_ext_bp.route("/listings/async/", methods=["GET"]) @require_auth() def get_async_listing_status(task_id): """Get status of an async ML publishing task.""" try: from celery.result import AsyncResult from app import celery as celery_app result = AsyncResult(task_id, app=celery_app) if result.ready(): return jsonify({"status": "done", "result": result.result or {}}) return jsonify({"status": "pending"}) except Exception as e: return jsonify({"error": str(e)}), 500 @marketplace_ext_bp.route("/listings//sync", methods=["POST"]) @require_auth() def sync_listing(listing_id): err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.sync_listing(conn, listing_id) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings//pause", methods=["POST"]) @require_auth() def pause_listing(listing_id): err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.pause_listing(conn, listing_id) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings//activate", methods=["POST"]) @require_auth() def activate_listing(listing_id): err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.activate_listing(conn, listing_id) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings/", methods=["DELETE"]) @require_auth() def delete_listing(listing_id): err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.close_listing(conn, listing_id) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings//permanent", methods=["DELETE"]) @require_auth() def delete_listing_permanent(listing_id): """Hard-delete a closed listing from the local DB.""" err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.delete_listing_permanently(conn, listing_id) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() # ═══════════════════════════════════════════════════════════════════════════ # QUESTIONS & ANSWERS # ═══════════════════════════════════════════════════════════════════════════ @marketplace_ext_bp.route("/questions", methods=["GET"]) @require_auth() def list_questions(): """List questions from local DB. Query param: ?status=unanswered""" status = request.args.get("status") conn = get_tenant_conn(g.tenant_id) try: items = meli_svc.list_local_questions(conn, status=status) return jsonify({"items": items}) except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/questions/sync", methods=["POST"]) @require_auth() def sync_questions(): """Force sync questions from ML for all active listings.""" err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.sync_questions(conn) return jsonify(result) except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/questions//answer", methods=["POST"]) @require_auth() def answer_question(question_id): """Answer a buyer question via ML API.""" err = _require_meli_manage() if err: return err data = request.get_json() or {} text = data.get("text", "").strip() if not text: return jsonify({"error": "Answer text is required"}), 400 conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.answer_question(conn, question_id, text) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() # ═══════════════════════════════════════════════════════════════════════════ # ORDERS # ═══════════════════════════════════════════════════════════════════════════ @marketplace_ext_bp.route("/orders", methods=["GET"]) @require_auth() def list_orders(): page = int(request.args.get("page", 1)) per_page = min(int(request.args.get("per_page", 50)), 200) status = request.args.get("status") conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.get_orders(conn, page=page, per_page=per_page, status=status) return jsonify(result) finally: conn.close() @marketplace_ext_bp.route("/orders/", methods=["GET"]) @require_auth() def get_order(order_id): conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.get_order_detail(conn, order_id) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 404 finally: conn.close() @marketplace_ext_bp.route("/orders//convert", methods=["POST"]) @require_auth("pos.sell") def convert_order(order_id): data = request.get_json() or {} register_id = data.get("register_id") conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.convert_order_to_sale( conn, order_id, employee_id=g.employee_id, register_id=register_id ) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/orders//status", methods=["POST"]) @require_auth() def update_order_status_route(order_id): data = request.get_json() or {} new_status = data.get("status") if not new_status: return jsonify({"error": "status required"}), 400 conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.update_order_status(conn, order_id, new_status) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() # ═══════════════════════════════════════════════════════════════════════════ # WEBHOOK (public — no auth) # ═══════════════════════════════════════════════════════════════════════════ @marketplace_ext_bp.route("/webhook/meli", methods=["POST"]) def meli_webhook(): """Receive MercadoLibre notifications. ML sends a lightweight payload with topic + resource URL. We ack immediately and enqueue Celery for async processing. """ data = request.get_json(force=True, silent=True) or {} topic = data.get("topic", "") resource = data.get("resource", "") user_id = data.get("user_id") # Resolve tenant by meli_user_id tenant_id = None if user_id: try: mconn = get_master_conn() mcur = mconn.cursor() mcur.execute( """ SELECT t.id FROM tenants t JOIN tenant_config c ON c.key = 'meli_user_id' AND c.value = %s WHERE t.is_active = true LIMIT 1 """, (str(user_id),), ) row = mcur.fetchone() if row: tenant_id = row[0] mcur.close() mconn.close() except Exception: pass if tenant_id and topic: try: from tasks import process_meli_webhook_task process_meli_webhook_task.delay(tenant_id, topic, resource) except Exception as e: print(f"[ML Webhook] Failed to enqueue task: {e}") return jsonify({"ok": True})