From 0ce599c4ace669109eb1098725cd58a6b60e8a0f Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 31 Mar 2026 20:39:07 -0400 Subject: [PATCH] feat: add webhook, API, and product search controllers Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controllers/__init__.py | 4 +- .../fusion_woocommerce/controllers/api.py | 169 ++++++++++++++++++ .../controllers/product_search.py | 147 +++++++++++++++ .../fusion_woocommerce/controllers/webhook.py | 158 ++++++++++++++++ 4 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 fusion-woo-odoo/fusion_woocommerce/controllers/api.py create mode 100644 fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py create mode 100644 fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py diff --git a/fusion-woo-odoo/fusion_woocommerce/controllers/__init__.py b/fusion-woo-odoo/fusion_woocommerce/controllers/__init__.py index e73e2110..4217575d 100644 --- a/fusion-woo-odoo/fusion_woocommerce/controllers/__init__.py +++ b/fusion-woo-odoo/fusion_woocommerce/controllers/__init__.py @@ -1 +1,3 @@ -# Controllers will be imported here +from . import webhook +from . import api +from . import product_search diff --git a/fusion-woo-odoo/fusion_woocommerce/controllers/api.py b/fusion-woo-odoo/fusion_woocommerce/controllers/api.py new file mode 100644 index 00000000..f0f31142 --- /dev/null +++ b/fusion-woo-odoo/fusion_woocommerce/controllers/api.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +import logging + +from odoo import http +from odoo.exceptions import AccessDenied +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class WooApiController(http.Controller): + """REST endpoints consumed by the WooCommerce WordPress plugin.""" + + def _authenticate_instance(self): + """ + Validate Bearer token from Authorization header against woo.instance.odoo_api_key. + Returns the matching woo.instance or raises AccessDenied. + """ + auth_header = request.httprequest.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + raise AccessDenied() + + api_key = auth_header[len('Bearer '):] + if not api_key: + raise AccessDenied() + + instance = request.env['woo.instance'].sudo().search([ + ('odoo_api_key', '=', api_key), + ], limit=1) + + if not instance: + raise AccessDenied() + + return instance + + # ------------------------------------------------------------------------- + # Endpoints + # ------------------------------------------------------------------------- + + @http.route( + '/woo/api/order/documents', + type='jsonrpc', auth='none', csrf=False, methods=['POST'], + ) + def order_documents(self, order_id=None, **kw): + """ + Fetch invoice and delivery PDF URLs for a WooCommerce order. + + Expected payload: {"order_id": } + Returns: {"invoices": [...], "deliveries": [...]} + """ + try: + instance = self._authenticate_instance() + except AccessDenied: + return {'error': 'Unauthorized', 'code': 401} + + if not order_id: + return {'error': 'order_id is required', 'code': 400} + + _logger.info( + "WooCommerce API: order/documents requested. Instance: %s, Order: %s", + instance.name, order_id, + ) + + # Placeholder — data fetching will be implemented in later tasks + return { + 'order_id': order_id, + 'invoices': [], + 'deliveries': [], + } + + @http.route( + '/woo/api/order/status', + type='jsonrpc', auth='none', csrf=False, methods=['POST'], + ) + def order_status(self, order_id=None, **kw): + """ + Fetch order status and timeline data for a WooCommerce order. + + Expected payload: {"order_id": } + Returns: {"status": ..., "timeline": [...]} + """ + try: + instance = self._authenticate_instance() + except AccessDenied: + return {'error': 'Unauthorized', 'code': 401} + + if not order_id: + return {'error': 'order_id is required', 'code': 400} + + _logger.info( + "WooCommerce API: order/status requested. Instance: %s, Order: %s", + instance.name, order_id, + ) + + # Placeholder — data fetching will be implemented in later tasks + return { + 'order_id': order_id, + 'status': None, + 'odoo_state': None, + 'timeline': [], + } + + @http.route( + '/woo/api/order/messages', + type='jsonrpc', auth='none', csrf=False, methods=['POST'], + ) + def order_messages(self, order_id=None, **kw): + """ + Fetch customer-visible messages for a WooCommerce order. + + Expected payload: {"order_id": } + Returns: {"messages": [...]} + """ + try: + instance = self._authenticate_instance() + except AccessDenied: + return {'error': 'Unauthorized', 'code': 401} + + if not order_id: + return {'error': 'order_id is required', 'code': 400} + + _logger.info( + "WooCommerce API: order/messages requested. Instance: %s, Order: %s", + instance.name, order_id, + ) + + # Placeholder — data fetching will be implemented in later tasks + return { + 'order_id': order_id, + 'messages': [], + } + + @http.route( + '/woo/api/return/create', + type='jsonrpc', auth='none', csrf=False, methods=['POST'], + ) + def return_create(self, order_id=None, reason=None, items=None, **kw): + """ + Submit a return request from the WooCommerce plugin. + + Expected payload: { + "order_id": , + "reason": "", + "items": [{"product_id": ..., "quantity": ...}, ...] + } + Returns: {"success": True, "return_id": } or {"error": ...} + """ + try: + instance = self._authenticate_instance() + except AccessDenied: + return {'error': 'Unauthorized', 'code': 401} + + if not order_id: + return {'error': 'order_id is required', 'code': 400} + + _logger.info( + "WooCommerce API: return/create requested. Instance: %s, Order: %s", + instance.name, order_id, + ) + + # Placeholder — return creation logic will be implemented in later tasks + return { + 'success': True, + 'return_id': None, + 'message': 'Return request received.', + } diff --git a/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py b/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py new file mode 100644 index 00000000..026e75e6 --- /dev/null +++ b/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +import logging + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class WooProductSearchController(http.Controller): + """AJAX search endpoints used by the product mapping UI.""" + + # ------------------------------------------------------------------------- + # Endpoints + # ------------------------------------------------------------------------- + + @http.route( + '/woo/search/odoo_products', + type='jsonrpc', auth='user', methods=['POST'], + ) + def search_odoo_products(self, query='', instance_id=None, limit=20, **kw): + """ + Search Odoo products by name or internal reference (SKU). + + Params: + query (str): Search string matched against name and default_code. + instance_id (int): woo.instance ID (used for future per-instance filtering). + limit (int): Max results to return (default 20). + + Returns: + list of {id, name, default_code, list_price, qty_available} + """ + limit = min(int(limit or 20), 100) + domain = [] + + if query: + domain = [ + '|', + ('name', 'ilike', query), + ('default_code', 'ilike', query), + ] + + products = request.env['product.product'].search(domain, limit=limit) + + return [ + { + 'id': p.id, + 'name': p.name, + 'default_code': p.default_code or '', + 'list_price': p.list_price, + 'qty_available': p.qty_available, + } + for p in products + ] + + @http.route( + '/woo/search/woo_products', + type='jsonrpc', auth='user', methods=['POST'], + ) + def search_woo_products(self, query='', instance_id=None, limit=20, **kw): + """ + Search unmapped WooCommerce products from the woo.product.map model. + + Params: + query (str): Search string matched against woo_product_name and woo_sku. + instance_id (int): woo.instance ID — filters results to this instance. + limit (int): Max results to return (default 20). + + Returns: + list of {id, woo_product_id, woo_product_name, woo_sku, woo_product_type} + """ + limit = min(int(limit or 20), 100) + domain = [('state', '=', 'unmapped')] + + if instance_id: + domain.append(('instance_id', '=', int(instance_id))) + + if query: + domain += [ + '|', + ('woo_product_name', 'ilike', query), + ('woo_sku', 'ilike', query), + ] + + maps = request.env['woo.product.map'].search(domain, limit=limit) + + return [ + { + 'id': m.id, + 'woo_product_id': m.woo_product_id, + 'woo_product_name': m.woo_product_name or '', + 'woo_sku': m.woo_sku or '', + 'woo_product_type': m.woo_product_type or '', + } + for m in maps + ] + + @http.route( + '/woo/search/mapped', + type='jsonrpc', auth='user', methods=['POST'], + ) + def search_mapped(self, query='', instance_id=None, limit=20, **kw): + """ + Search mapped WooCommerce ↔ Odoo product pairs. + + Params: + query (str): Matched against woo_product_name, woo_sku, and linked product name. + instance_id (int): woo.instance ID — filters results to this instance. + limit (int): Max results to return (default 20). + + Returns: + list of mapped product data dicts + """ + limit = min(int(limit or 20), 100) + domain = [('state', '=', 'mapped')] + + if instance_id: + domain.append(('instance_id', '=', int(instance_id))) + + if query: + domain += [ + '|', '|', + ('woo_product_name', 'ilike', query), + ('woo_sku', 'ilike', query), + ('product_id.name', 'ilike', query), + ] + + maps = request.env['woo.product.map'].search(domain, limit=limit) + + return [ + { + 'id': m.id, + 'woo_product_id': m.woo_product_id, + 'woo_product_name': m.woo_product_name or '', + 'woo_sku': m.woo_sku or '', + 'woo_product_type': m.woo_product_type or '', + 'odoo_product_id': m.product_id.id if m.product_id else False, + 'odoo_product_name': m.product_id.name if m.product_id else '', + 'odoo_default_code': m.product_id.default_code or '' if m.product_id else '', + 'instance_id': m.instance_id.id if m.instance_id else False, + 'instance_name': m.instance_id.name if m.instance_id else '', + } + for m in maps + ] diff --git a/fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py b/fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py new file mode 100644 index 00000000..9f5033fd --- /dev/null +++ b/fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +import json +import logging + +from odoo import http +from odoo.http import request, Response + +from ..lib.woo_api_client import WooApiClient + +_logger = logging.getLogger(__name__) + + +def _normalize_url(url): + """Strip trailing slashes and lowercase for comparison.""" + return url.rstrip('/').lower() if url else '' + + +class WooWebhookController(http.Controller): + """Receive inbound WooCommerce webhook deliveries.""" + + def _find_instance(self, source_url): + """Find a woo.instance matching the webhook source URL.""" + instances = request.env['woo.instance'].sudo().search([ + ('state', '!=', 'draft'), + ]) + norm_source = _normalize_url(source_url) + for inst in instances: + if _normalize_url(inst.url) == norm_source: + return inst + return None + + def _handle_ping(self, topic): + """Return 200 for WooCommerce webhook test deliveries.""" + _logger.info("WooCommerce webhook ping received. Topic: %s", topic) + return Response('OK', status=200) + + def _verify_and_dispatch(self, dispatch_method): + """ + Common handler for all webhook endpoints. + - Detects ping + - Finds instance by source URL + - Verifies HMAC signature + - Delegates to dispatch_method(instance, data) + Returns a Response. + """ + headers = request.httprequest.headers + topic = headers.get('X-WC-Webhook-Topic', '') + source_url = headers.get('X-WC-Webhook-Source', '') + signature = headers.get('X-WC-Webhook-Signature', '') + + payload = request.httprequest.get_data() + + # WooCommerce sends a test ping on webhook creation — body may be empty or minimal + if not payload or payload.strip() in (b'', b'{}', b'[]'): + return self._handle_ping(topic) + + # Find matching instance + instance = self._find_instance(source_url) + if not instance: + _logger.warning( + "WooCommerce webhook: no matching instance for source URL '%s'", source_url + ) + # Return 200 to prevent WooCommerce from retrying indefinitely + return Response('No matching instance', status=200) + + # Verify HMAC signature + if instance.webhook_secret: + if not WooApiClient.verify_webhook_signature(payload, signature, instance.webhook_secret): + _logger.warning( + "WooCommerce webhook: invalid signature for instance '%s'", instance.name + ) + return Response('Unauthorized', status=401) + else: + _logger.warning( + "WooCommerce webhook: instance '%s' has no webhook_secret — skipping signature check", + instance.name, + ) + + # Parse JSON body + try: + data = json.loads(payload) + except (json.JSONDecodeError, ValueError) as exc: + _logger.error("WooCommerce webhook: invalid JSON body — %s", exc) + return Response('Bad Request', status=400) + + # Dispatch to model method + try: + dispatch_method(instance, data, topic) + except Exception: + _logger.exception( + "WooCommerce webhook: error dispatching topic '%s' for instance '%s'", + topic, instance.name, + ) + return Response('Internal Error', status=500) + + return Response('OK', status=200) + + # ------------------------------------------------------------------------- + # Webhook Endpoints + # ------------------------------------------------------------------------- + + @http.route( + '/woo/webhook/order', + type='http', auth='none', csrf=False, methods=['POST'], + save_session=False, + ) + def webhook_order(self, **kw): + """Receive order.created / order.updated from WooCommerce.""" + + def dispatch(instance, data, topic): + _logger.info( + "WooCommerce order webhook received. Instance: %s, Topic: %s, Order ID: %s", + instance.name, topic, data.get('id'), + ) + # Delegate to model — will be implemented in later tasks + if hasattr(instance, '_sync_order_from_wc'): + instance._sync_order_from_wc(data) + + return self._verify_and_dispatch(dispatch) + + @http.route( + '/woo/webhook/product', + type='http', auth='none', csrf=False, methods=['POST'], + save_session=False, + ) + def webhook_product(self, **kw): + """Receive product.updated from WooCommerce.""" + + def dispatch(instance, data, topic): + _logger.info( + "WooCommerce product webhook received. Instance: %s, Topic: %s, Product ID: %s", + instance.name, topic, data.get('id'), + ) + if hasattr(instance, '_sync_product_from_wc'): + instance._sync_product_from_wc(data) + + return self._verify_and_dispatch(dispatch) + + @http.route( + '/woo/webhook/customer', + type='http', auth='none', csrf=False, methods=['POST'], + save_session=False, + ) + def webhook_customer(self, **kw): + """Receive customer.created / customer.updated from WooCommerce.""" + + def dispatch(instance, data, topic): + _logger.info( + "WooCommerce customer webhook received. Instance: %s, Topic: %s, Customer ID: %s", + instance.name, topic, data.get('id'), + ) + if hasattr(instance, '_sync_customer_from_wc'): + instance._sync_customer_from_wc(data) + + return self._verify_and_dispatch(dispatch)