- Override account.move action_post to auto-push invoice PDF to WC on posting - Add _cron_health_check to ping all instances and flag unreachable ones - Add health check cron record (every 10 minutes) to data/cron.xml - Add IP-based rate limiting (100 req/min) to webhook controller - Fill in API endpoints: order/documents, order/status, order/messages with real data from woo.order, stock.picking, and mail.message - Implement return/create API endpoint with product mapping and line creation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
293 lines
10 KiB
Python
293 lines
10 KiB
Python
# -*- 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
|
|
|
|
def _find_woo_order(self, instance, order_id):
|
|
"""Look up a woo.order by WC order ID for a given instance."""
|
|
return request.env['woo.order'].sudo().search([
|
|
('instance_id', '=', instance.id),
|
|
('woo_order_id', '=', int(order_id)),
|
|
], limit=1)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# 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}
|
|
|
|
woo_order = self._find_woo_order(instance, order_id)
|
|
if not woo_order:
|
|
return {'order_id': order_id, 'invoices': [], 'deliveries': []}
|
|
|
|
invoices = []
|
|
if woo_order.invoice_id:
|
|
inv = woo_order.invoice_id
|
|
invoices.append({
|
|
'id': inv.id,
|
|
'name': inv.name,
|
|
'state': inv.state,
|
|
'amount_total': inv.amount_total,
|
|
'date': str(inv.invoice_date) if inv.invoice_date else '',
|
|
})
|
|
|
|
deliveries = []
|
|
if woo_order.sale_order_id:
|
|
pickings = request.env['stock.picking'].sudo().search([
|
|
('origin', '=', woo_order.sale_order_id.name),
|
|
('picking_type_code', '=', 'outgoing'),
|
|
])
|
|
for picking in pickings:
|
|
deliveries.append({
|
|
'id': picking.id,
|
|
'name': picking.name,
|
|
'state': picking.state,
|
|
'tracking_number': picking.woo_tracking_number or '',
|
|
'scheduled_date': str(picking.scheduled_date) if picking.scheduled_date else '',
|
|
})
|
|
|
|
return {
|
|
'order_id': order_id,
|
|
'invoices': invoices,
|
|
'deliveries': 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}
|
|
|
|
woo_order = self._find_woo_order(instance, order_id)
|
|
if not woo_order:
|
|
return {
|
|
'order_id': order_id,
|
|
'status': None,
|
|
'odoo_state': None,
|
|
'timeline': [],
|
|
}
|
|
|
|
# Build timeline from tracking messages
|
|
timeline = []
|
|
if woo_order.sale_order_id:
|
|
messages = request.env['mail.message'].sudo().search([
|
|
('res_id', '=', woo_order.sale_order_id.id),
|
|
('model', '=', 'sale.order'),
|
|
], order='create_date asc')
|
|
for msg in messages:
|
|
timeline.append({
|
|
'date': str(msg.create_date),
|
|
'type': msg.message_type,
|
|
'body': msg.preview or '',
|
|
})
|
|
|
|
# Add shipment events
|
|
for shipment in woo_order.shipment_ids:
|
|
timeline.append({
|
|
'date': str(shipment.shipped_date) if shipment.shipped_date else '',
|
|
'type': 'shipment',
|
|
'body': f'Shipped via {shipment.carrier_id.name if shipment.carrier_id else "carrier"} — tracking: {shipment.tracking_number or "N/A"}',
|
|
})
|
|
|
|
return {
|
|
'order_id': order_id,
|
|
'status': woo_order.woo_status,
|
|
'odoo_state': woo_order.state,
|
|
'odoo_order_ref': woo_order.sale_order_id.name if woo_order.sale_order_id else '',
|
|
'timeline': 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}
|
|
|
|
woo_order = self._find_woo_order(instance, order_id)
|
|
if not woo_order or not woo_order.sale_order_id:
|
|
return {'order_id': order_id, 'messages': []}
|
|
|
|
# Get customer-visible messages
|
|
messages = request.env['mail.message'].sudo().search([
|
|
('res_id', '=', woo_order.sale_order_id.id),
|
|
('model', '=', 'sale.order'),
|
|
('message_type', 'in', ['comment', 'email']),
|
|
('subtype_id.internal', '=', False),
|
|
], order='create_date asc')
|
|
|
|
result = []
|
|
for msg in messages:
|
|
result.append({
|
|
'date': str(msg.create_date),
|
|
'author': msg.author_id.name if msg.author_id else '',
|
|
'body': msg.body or '',
|
|
})
|
|
|
|
return {
|
|
'order_id': order_id,
|
|
'messages': result,
|
|
}
|
|
|
|
@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": ..., "reason": ...}, ...]
|
|
}
|
|
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}
|
|
|
|
woo_order = self._find_woo_order(instance, order_id)
|
|
if not woo_order:
|
|
return {'error': 'Order not found', 'code': 404}
|
|
|
|
items = items or []
|
|
if not items:
|
|
return {'error': 'At least one return item is required', 'code': 400}
|
|
|
|
# Create woo.return
|
|
woo_return = request.env['woo.return'].sudo().create({
|
|
'instance_id': instance.id,
|
|
'order_id': woo_order.id,
|
|
'reason': reason or '',
|
|
'company_id': instance.company_id.id,
|
|
})
|
|
|
|
# Create return lines
|
|
for item in items:
|
|
wc_product_id = item.get('product_id')
|
|
quantity = item.get('quantity', 1)
|
|
item_reason = item.get('reason', 'other')
|
|
|
|
# Find mapped Odoo product
|
|
product = False
|
|
if wc_product_id:
|
|
pm = request.env['woo.product.map'].sudo().search([
|
|
('instance_id', '=', instance.id),
|
|
('woo_product_id', '=', wc_product_id),
|
|
('state', '=', 'mapped'),
|
|
], limit=1)
|
|
if pm:
|
|
product = pm.product_id
|
|
|
|
if not product:
|
|
# Try SKU from the item
|
|
sku = item.get('sku', '')
|
|
if sku:
|
|
product = request.env['product.product'].sudo().search([
|
|
('default_code', '=', sku),
|
|
], limit=1)
|
|
|
|
if product:
|
|
request.env['woo.return.line'].sudo().create({
|
|
'return_id': woo_return.id,
|
|
'product_id': product.id,
|
|
'quantity': quantity,
|
|
'reason': item_reason if item_reason in dict(
|
|
request.env['woo.return.line']._fields['reason'].selection
|
|
) else 'other',
|
|
'company_id': instance.company_id.id,
|
|
})
|
|
|
|
instance._log_sync(
|
|
'order', 'woo_to_odoo',
|
|
woo_order.sale_order_id.name if woo_order.sale_order_id else f'WC#{order_id}',
|
|
'success', f'Return request created with {len(items)} item(s)',
|
|
)
|
|
|
|
return {
|
|
'success': True,
|
|
'return_id': woo_return.id,
|
|
'message': 'Return request received.',
|
|
}
|