diff --git a/fusion-woo-odoo/fusion_woocommerce/controllers/api.py b/fusion-woo-odoo/fusion_woocommerce/controllers/api.py index f0f31142..175881d9 100644 --- a/fusion-woo-odoo/fusion_woocommerce/controllers/api.py +++ b/fusion-woo-odoo/fusion_woocommerce/controllers/api.py @@ -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": , "reason": "", - "items": [{"product_id": ..., "quantity": ...}, ...] + "items": [{"product_id": ..., "quantity": ..., "reason": ...}, ...] } Returns: {"success": True, "return_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.', } diff --git a/fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py b/fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py index 9f5033fd..7aa8cd4b 100644 --- a/fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py +++ b/fusion-woo-odoo/fusion_woocommerce/controllers/webhook.py @@ -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) diff --git a/fusion-woo-odoo/fusion_woocommerce/data/cron.xml b/fusion-woo-odoo/fusion_woocommerce/data/cron.xml index 30b3ee3d..8a4ed01e 100644 --- a/fusion-woo-odoo/fusion_woocommerce/data/cron.xml +++ b/fusion-woo-odoo/fusion_woocommerce/data/cron.xml @@ -44,6 +44,17 @@ True + + WooCommerce: Health Check + + code + model._cron_health_check() + 10 + minutes + -1 + True + + WooCommerce: Cleanup Old Sync Logs diff --git a/fusion-woo-odoo/fusion_woocommerce/models/account_move.py b/fusion-woo-odoo/fusion_woocommerce/models/account_move.py index 69b44a2b..e8930e53 100644 --- a/fusion-woo-odoo/fusion_woocommerce/models/account_move.py +++ b/fusion-woo-odoo/fusion_woocommerce/models/account_move.py @@ -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