Split 49 modules/suites into independent git repos; untrack from monorepo
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled

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:
gsinghpal
2026-06-07 01:54:34 -04:00
parent 2a7b315e98
commit a66cdefc01
6740 changed files with 51 additions and 1277207 deletions

View File

@@ -1,3 +0,0 @@
from . import webhook
from . import api
from . import product_search

View File

@@ -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.',
}

View File

@@ -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,
}

View File

@@ -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)