Split 49 modules/suites into independent git repos; untrack from monorepo
Each top-level module/suite folder is now its own private repo on GitHub (gsinghpal/<name>) and gitea (admin/<name>), with a fresh single initial commit. The monorepo no longer tracks them (added to .gitignore + git rm --cached); working-tree files are retained on disk and managed in their own repos. The monorepo keeps shared root files (CLAUDE.md, docs/, scripts/, tools/, AGENTS.md, WIP/obsolete dirs) and full history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
from . import webhook
|
||||
from . import api
|
||||
from . import product_search
|
||||
@@ -1,344 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.exceptions import AccessDenied
|
||||
from odoo.http import request, Response
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WooApiController(http.Controller):
|
||||
"""REST endpoints consumed by the WooCommerce WordPress plugin."""
|
||||
|
||||
@http.route('/woo/image/<int:line_id>/<string:filename>',
|
||||
type='http', auth='none', csrf=False, methods=['GET'])
|
||||
def serve_variant_image(self, line_id, filename, **kw):
|
||||
"""Serve a variant image from the transient wizard line.
|
||||
Used by WC to download images during variant push."""
|
||||
try:
|
||||
line = request.env['woo.variant.push.line'].sudo().browse(line_id)
|
||||
if not line.exists() or not line.image:
|
||||
_logger.warning("Image endpoint: line %d not found or no image", line_id)
|
||||
return request.not_found()
|
||||
img_data = line.image
|
||||
# Odoo Binary fields always return base64 string
|
||||
if isinstance(img_data, (str, bytes)):
|
||||
if isinstance(img_data, bytes):
|
||||
img_data = img_data.decode('utf-8')
|
||||
img_data = base64.b64decode(img_data)
|
||||
elif isinstance(img_data, memoryview):
|
||||
# Raw bytea from DB — still base64 encoded by ORM
|
||||
raw = bytes(img_data)
|
||||
try:
|
||||
img_data = base64.b64decode(raw)
|
||||
except Exception:
|
||||
img_data = raw
|
||||
# Detect content type from magic bytes
|
||||
content_type = 'image/png'
|
||||
if img_data[:2] == b'\xff\xd8':
|
||||
content_type = 'image/jpeg'
|
||||
elif img_data[:4] == b'\x89PNG':
|
||||
content_type = 'image/png'
|
||||
elif img_data[:4] == b'GIF8':
|
||||
content_type = 'image/gif'
|
||||
elif img_data[:4] == b'RIFF':
|
||||
content_type = 'image/webp'
|
||||
_logger.info("Serving image for line %d: %d bytes, %s", line_id, len(img_data), content_type)
|
||||
# Set extension-appropriate filename
|
||||
ext = content_type.split('/')[-1]
|
||||
if ext == 'jpeg':
|
||||
ext = 'jpg'
|
||||
return Response(
|
||||
img_data,
|
||||
content_type=content_type,
|
||||
status=200,
|
||||
headers={
|
||||
'Content-Disposition': f'inline; filename="{filename.rsplit(".", 1)[0]}.{ext}"',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("Failed to serve variant image %d: %s", line_id, e)
|
||||
return request.not_found()
|
||||
|
||||
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.',
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
# -*- 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, offset=0,
|
||||
category_id=None, exclude_category_ids=None,
|
||||
apply_excluded=False, **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).
|
||||
offset (int): Offset for pagination (default 0).
|
||||
category_id (int): Filter by Odoo product category.
|
||||
exclude_category_ids (list): Exclude these category IDs.
|
||||
|
||||
Returns:
|
||||
dict with 'results' list and 'total' count
|
||||
"""
|
||||
limit = min(int(limit or 20), 100)
|
||||
offset = int(offset or 0)
|
||||
domain = []
|
||||
|
||||
if query:
|
||||
domain = [
|
||||
'|',
|
||||
('name', 'ilike', query),
|
||||
('default_code', 'ilike', query),
|
||||
]
|
||||
|
||||
if category_id:
|
||||
domain.append(('categ_id', '=', int(category_id)))
|
||||
|
||||
if exclude_category_ids:
|
||||
if isinstance(exclude_category_ids, str):
|
||||
import json as _json
|
||||
try:
|
||||
exclude_category_ids = _json.loads(exclude_category_ids)
|
||||
except (ValueError, TypeError):
|
||||
exclude_category_ids = []
|
||||
if exclude_category_ids:
|
||||
domain.append(('categ_id', 'not in', [int(x) for x in exclude_category_ids]))
|
||||
|
||||
# Apply instance-level excluded categories
|
||||
if apply_excluded and instance_id:
|
||||
instance = request.env['woo.instance'].browse(int(instance_id))
|
||||
if instance.exists() and instance.excluded_category_ids:
|
||||
domain.append(('categ_id', 'not in', instance.excluded_category_ids.ids))
|
||||
|
||||
# Search product.template to group variants together
|
||||
tmpl_domain = []
|
||||
if query:
|
||||
tmpl_domain = [
|
||||
'|',
|
||||
('name', 'ilike', query),
|
||||
('default_code', 'ilike', query),
|
||||
]
|
||||
if category_id:
|
||||
tmpl_domain.append(('categ_id', '=', int(category_id)))
|
||||
if exclude_category_ids:
|
||||
if isinstance(exclude_category_ids, str):
|
||||
import json as _json2
|
||||
try:
|
||||
exclude_category_ids = _json2.loads(exclude_category_ids)
|
||||
except (ValueError, TypeError):
|
||||
exclude_category_ids = []
|
||||
if exclude_category_ids:
|
||||
tmpl_domain.append(('categ_id', 'not in', [int(x) for x in exclude_category_ids]))
|
||||
if apply_excluded and instance_id:
|
||||
instance = request.env['woo.instance'].browse(int(instance_id))
|
||||
if instance.exists() and instance.excluded_category_ids:
|
||||
tmpl_domain.append(('categ_id', 'not in', instance.excluded_category_ids.ids))
|
||||
|
||||
total = request.env['product.template'].search_count(tmpl_domain)
|
||||
templates = request.env['product.template'].search(tmpl_domain, limit=limit, offset=offset)
|
||||
|
||||
results = []
|
||||
for tmpl in templates:
|
||||
variant_count = len(tmpl.product_variant_ids)
|
||||
# Use first variant as representative
|
||||
first_variant = tmpl.product_variant_ids[:1]
|
||||
results.append({
|
||||
'id': first_variant.id if first_variant else tmpl.id,
|
||||
'template_id': tmpl.id,
|
||||
'name': tmpl.name,
|
||||
'default_code': tmpl.default_code or '',
|
||||
'list_price': tmpl.list_price,
|
||||
'qty_available': sum(tmpl.product_variant_ids.mapped('qty_available')),
|
||||
'categ_name': tmpl.categ_id.name if tmpl.categ_id else '',
|
||||
'variant_count': variant_count,
|
||||
'has_variants': variant_count > 1,
|
||||
})
|
||||
|
||||
return {
|
||||
'results': results,
|
||||
'total': total,
|
||||
}
|
||||
|
||||
@http.route(
|
||||
'/woo/search/woo_products',
|
||||
type='jsonrpc', auth='user', methods=['POST'],
|
||||
)
|
||||
def search_woo_products(self, query='', instance_id=None, limit=20, offset=0, **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).
|
||||
offset (int): Offset for pagination (default 0).
|
||||
|
||||
Returns:
|
||||
dict with 'results' list and 'total' count
|
||||
"""
|
||||
limit = min(int(limit or 20), 100)
|
||||
offset = int(offset or 0)
|
||||
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),
|
||||
]
|
||||
|
||||
total = request.env['woo.product.map'].search_count(domain)
|
||||
maps = request.env['woo.product.map'].search(domain, limit=limit, offset=offset)
|
||||
|
||||
return {
|
||||
'results': [
|
||||
{
|
||||
'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 '',
|
||||
'woo_category_name': m.woo_category_name or '',
|
||||
}
|
||||
for m in maps
|
||||
],
|
||||
'total': total,
|
||||
}
|
||||
|
||||
@http.route(
|
||||
'/woo/search/odoo_categories',
|
||||
type='jsonrpc', auth='user', methods=['POST'],
|
||||
)
|
||||
def get_odoo_categories(self, **kw):
|
||||
"""Return all Odoo product categories for filtering."""
|
||||
categories = request.env['product.category'].search([], order='complete_name')
|
||||
return [
|
||||
{'id': c.id, 'name': c.name, 'complete_name': c.complete_name}
|
||||
for c in categories
|
||||
]
|
||||
|
||||
@http.route(
|
||||
'/woo/search/mapped',
|
||||
type='jsonrpc', auth='user', methods=['POST'],
|
||||
)
|
||||
def search_mapped(self, query='', instance_id=None, limit=20, offset=0, **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).
|
||||
offset (int): Offset for pagination (default 0).
|
||||
|
||||
Returns:
|
||||
dict with 'results' list and 'total' count
|
||||
"""
|
||||
limit = min(int(limit or 20), 100)
|
||||
offset = int(offset or 0)
|
||||
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),
|
||||
]
|
||||
|
||||
total = request.env['woo.product.map'].search_count(domain)
|
||||
maps = request.env['woo.product.map'].search(domain, limit=limit, offset=offset)
|
||||
|
||||
return {
|
||||
'results': [
|
||||
{
|
||||
'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 '',
|
||||
'woo_permalink': m.woo_permalink 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 '',
|
||||
'odoo_price': m.product_id.list_price if m.product_id else 0.0,
|
||||
'odoo_cost': m.product_id.standard_price if m.product_id else 0.0,
|
||||
'woo_regular_price': m.woo_regular_price or 0.0,
|
||||
'woo_sale_price': m.woo_sale_price or 0.0,
|
||||
'sync_price': m.sync_price,
|
||||
'sync_inventory': m.sync_inventory,
|
||||
'instance_id': m.instance_id.id if m.instance_id else False,
|
||||
'instance_name': m.instance_id.name if m.instance_id else '',
|
||||
'is_variation': m.is_variation,
|
||||
'odoo_variant_count': len(m.product_id.product_tmpl_id.product_variant_ids) if m.product_id else 0,
|
||||
'wc_is_simple': (m.woo_product_type or 'simple') == 'simple' and not m.is_variation,
|
||||
'needs_variant_push': (
|
||||
m.product_id
|
||||
and not m.is_variation
|
||||
and (m.woo_product_type or 'simple') == 'simple'
|
||||
and len(m.product_id.product_tmpl_id.product_variant_ids) > 1
|
||||
),
|
||||
}
|
||||
for m in maps
|
||||
],
|
||||
'total': total,
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
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."""
|
||||
|
||||
# Simple in-memory rate limiter: {ip: [(timestamp, ...),]}
|
||||
_rate_tracker = defaultdict(list)
|
||||
_RATE_LIMIT = 60 # max requests per minute (1/sec sustained)
|
||||
_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([
|
||||
('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.
|
||||
- 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', '')
|
||||
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'),
|
||||
)
|
||||
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