feat: add communication history push, invoice auto-trigger, health checks, rate limiting
- 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>
This commit is contained in:
@@ -36,6 +36,13 @@ class WooApiController(http.Controller):
|
||||
|
||||
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
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -59,16 +66,40 @@ class WooApiController(http.Controller):
|
||||
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,
|
||||
)
|
||||
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 '',
|
||||
})
|
||||
|
||||
# Placeholder — data fetching will be implemented in later tasks
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'invoices': [],
|
||||
'deliveries': [],
|
||||
'invoices': invoices,
|
||||
'deliveries': deliveries,
|
||||
}
|
||||
|
||||
@http.route(
|
||||
@@ -90,17 +121,43 @@ class WooApiController(http.Controller):
|
||||
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,
|
||||
)
|
||||
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"}',
|
||||
})
|
||||
|
||||
# Placeholder — data fetching will be implemented in later tasks
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'status': None,
|
||||
'odoo_state': None,
|
||||
'timeline': [],
|
||||
'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(
|
||||
@@ -122,15 +179,29 @@ class WooApiController(http.Controller):
|
||||
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,
|
||||
)
|
||||
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 '',
|
||||
})
|
||||
|
||||
# Placeholder — data fetching will be implemented in later tasks
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'messages': [],
|
||||
'messages': result,
|
||||
}
|
||||
|
||||
@http.route(
|
||||
@@ -144,7 +215,7 @@ class WooApiController(http.Controller):
|
||||
Expected payload: {
|
||||
"order_id": <woo_order_id>,
|
||||
"reason": "<return reason>",
|
||||
"items": [{"product_id": ..., "quantity": ...}, ...]
|
||||
"items": [{"product_id": ..., "quantity": ..., "reason": ...}, ...]
|
||||
}
|
||||
Returns: {"success": True, "return_id": <id>} or {"error": ...}
|
||||
"""
|
||||
@@ -156,14 +227,66 @@ class WooApiController(http.Controller):
|
||||
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,
|
||||
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)',
|
||||
)
|
||||
|
||||
# Placeholder — return creation logic will be implemented in later tasks
|
||||
return {
|
||||
'success': True,
|
||||
'return_id': None,
|
||||
'return_id': woo_return.id,
|
||||
'message': 'Return request received.',
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request, Response
|
||||
@@ -21,6 +23,25 @@ def _normalize_url(url):
|
||||
class WooWebhookController(http.Controller):
|
||||
"""Receive inbound WooCommerce webhook deliveries."""
|
||||
|
||||
# Simple in-memory rate limiter: {ip: [(timestamp, ...),]}
|
||||
_rate_tracker = defaultdict(list)
|
||||
_RATE_LIMIT = 100 # max requests per minute
|
||||
_RATE_WINDOW = 60 # seconds
|
||||
|
||||
@classmethod
|
||||
def _check_rate_limit(cls, ip):
|
||||
"""Return True if the IP is within rate limits, False if exceeded."""
|
||||
now = time.time()
|
||||
cutoff = now - cls._RATE_WINDOW
|
||||
# Clean old entries
|
||||
cls._rate_tracker[ip] = [
|
||||
ts for ts in cls._rate_tracker[ip] if ts > cutoff
|
||||
]
|
||||
if len(cls._rate_tracker[ip]) >= cls._RATE_LIMIT:
|
||||
return False
|
||||
cls._rate_tracker[ip].append(now)
|
||||
return True
|
||||
|
||||
def _find_instance(self, source_url):
|
||||
"""Find a woo.instance matching the webhook source URL."""
|
||||
instances = request.env['woo.instance'].sudo().search([
|
||||
@@ -40,12 +61,19 @@ class WooWebhookController(http.Controller):
|
||||
def _verify_and_dispatch(self, dispatch_method):
|
||||
"""
|
||||
Common handler for all webhook endpoints.
|
||||
- Rate limits by IP
|
||||
- Detects ping
|
||||
- Finds instance by source URL
|
||||
- Verifies HMAC signature
|
||||
- Delegates to dispatch_method(instance, data)
|
||||
Returns a Response.
|
||||
"""
|
||||
# Rate limiting
|
||||
remote_ip = request.httprequest.remote_addr or 'unknown'
|
||||
if not self._check_rate_limit(remote_ip):
|
||||
_logger.warning("Rate limit exceeded for IP %s", remote_ip)
|
||||
return Response('Too Many Requests', status=429)
|
||||
|
||||
headers = request.httprequest.headers
|
||||
topic = headers.get('X-WC-Webhook-Topic', '')
|
||||
source_url = headers.get('X-WC-Webhook-Source', '')
|
||||
@@ -115,7 +143,6 @@ class WooWebhookController(http.Controller):
|
||||
"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)
|
||||
|
||||
|
||||
@@ -44,6 +44,17 @@
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<record id="cron_woo_health_check" model="ir.cron">
|
||||
<field name="name">WooCommerce: Health Check</field>
|
||||
<field name="model_id" ref="model_woo_instance"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_health_check()</field>
|
||||
<field name="interval_number">10</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<record id="cron_woo_cleanup_logs" model="ir.cron">
|
||||
<field name="name">WooCommerce: Cleanup Old Sync Logs</field>
|
||||
<field name="model_id" ref="model_woo_sync_log"/>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import logging
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
@@ -10,3 +14,15 @@ class AccountMove(models.Model):
|
||||
def _compute_is_woo_invoice(self):
|
||||
for move in self:
|
||||
move.is_woo_invoice = bool(move.woo_order_id)
|
||||
|
||||
def action_post(self):
|
||||
"""Override to auto-push invoice PDF to WooCommerce on posting."""
|
||||
res = super().action_post()
|
||||
for move in self:
|
||||
if move.woo_order_id and not move.woo_order_id.invoice_synced:
|
||||
try:
|
||||
move.woo_order_id.action_push_invoice_pdf()
|
||||
move.woo_order_id.invoice_synced = True
|
||||
except Exception as e:
|
||||
_logger.error("Failed to push invoice PDF to WC: %s", e)
|
||||
return res
|
||||
|
||||
Reference in New Issue
Block a user