feat: add webhook, API, and product search controllers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,3 @@
|
|||||||
# Controllers will be imported here
|
from . import webhook
|
||||||
|
from . import api
|
||||||
|
from . import product_search
|
||||||
|
|||||||
169
fusion-woo-odoo/fusion_woocommerce/controllers/api.py
Normal file
169
fusion-woo-odoo/fusion_woocommerce/controllers/api.py
Normal file
@@ -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": <woo_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": <woo_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": <woo_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": <woo_order_id>,
|
||||||
|
"reason": "<return reason>",
|
||||||
|
"items": [{"product_id": ..., "quantity": ...}, ...]
|
||||||
|
}
|
||||||
|
Returns: {"success": True, "return_id": <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.',
|
||||||
|
}
|
||||||
147
fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py
Normal file
147
fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py
Normal file
@@ -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
|
||||||
|
]
|
||||||
158
fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py
Normal file
158
fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user