Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X. WordPress media pipeline - Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries. Capability discovery (F.7e) - Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error. Companion plugin overhaul - Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge. - Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach. - wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL. Other - Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork. - Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8). - n8n refactor: structured errors, capability probe, missing tools backfilled. - Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites. Docs - README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it. - Tools/Tests badges removed in favour of "Plugins: 10". Deployment - PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant. CI - Black + Ruff clean on Python 3.12; pytest tests/ green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
438 lines
17 KiB
Python
438 lines
17 KiB
Python
"""
|
|
WooCommerce Plugin - E-commerce Management
|
|
|
|
Split from WordPress Core in Phase D.1.
|
|
Provides 28 tools for WooCommerce store management.
|
|
|
|
Uses shared WordPress handlers for implementation.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from plugins.base import BasePlugin
|
|
from plugins.wordpress.client import WordPressClient
|
|
from plugins.wordpress.handlers import (
|
|
AIMediaHandler,
|
|
CouponsHandler,
|
|
CustomersHandler,
|
|
MediaAttachHandler,
|
|
OrdersHandler,
|
|
ProductsHandler,
|
|
ReportsHandler,
|
|
get_ai_media_specs,
|
|
get_coupons_specs,
|
|
get_customers_specs,
|
|
get_media_attach_specs,
|
|
get_orders_specs,
|
|
get_products_specs,
|
|
get_reports_specs,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WooCommercePlugin(BasePlugin):
|
|
"""
|
|
WooCommerce E-commerce Plugin.
|
|
|
|
Provides comprehensive WooCommerce management capabilities:
|
|
- Products (12 tools): CRUD, categories, tags, attributes, variations
|
|
- Orders (5 tools): list, get, create, update_status, delete
|
|
- Customers (4 tools): list, get, create, update
|
|
- Coupons (4 tools): list, create, update, delete
|
|
- Reports (3 tools): sales, top_sellers, customer_report
|
|
|
|
Total: 28 tools
|
|
"""
|
|
|
|
@staticmethod
|
|
def get_plugin_name() -> str:
|
|
"""Return plugin type identifier"""
|
|
return "woocommerce"
|
|
|
|
@staticmethod
|
|
def get_required_config_keys() -> list[str]:
|
|
"""Return required configuration keys"""
|
|
return ["url"]
|
|
|
|
def __init__(self, config: dict[str, Any], project_id: str | None = None):
|
|
"""
|
|
Initialize WooCommerce plugin with handlers.
|
|
|
|
Args:
|
|
config: Configuration dictionary containing:
|
|
- url: WordPress/WooCommerce site URL
|
|
- consumer_key/consumer_secret: WooCommerce REST API keys (preferred)
|
|
- username/app_password: WordPress application password (fallback)
|
|
- wp_username/wp_app_password: optional WP Application Password
|
|
for /wp/v2/media calls (used by media tools — F.X.fix-pass4).
|
|
project_id: Optional project ID (auto-generated if not provided)
|
|
"""
|
|
super().__init__(config, project_id=project_id)
|
|
|
|
# WooCommerce supports two credential formats:
|
|
# 1. consumer_key/consumer_secret (WooCommerce REST API keys — preferred)
|
|
# 2. username/app_password (WordPress Application Passwords — legacy fallback)
|
|
username = config.get("consumer_key") or config.get("username")
|
|
password = config.get("consumer_secret") or config.get("app_password")
|
|
|
|
if not username or not password:
|
|
from plugins.wordpress.client import ConfigurationError
|
|
|
|
raise ConfigurationError(
|
|
"WooCommerce credentials not configured. "
|
|
"Please set either CONSUMER_KEY/CONSUMER_SECRET or USERNAME/APP_PASSWORD."
|
|
)
|
|
|
|
# Create WordPress API client (used for /wc/v3/* WooCommerce REST).
|
|
self.client = WordPressClient(
|
|
site_url=config["url"], username=username, app_password=password
|
|
)
|
|
|
|
# F.X.fix-pass4 — derive a SECONDARY client for /wp/v2/* media
|
|
# uploads. WC's Consumer Key + Secret pair does NOT authenticate
|
|
# WP core REST endpoints; uploads to /wp/v2/media require an
|
|
# Application Password from the WP admin user. Three resolution
|
|
# paths, first that wins:
|
|
# 1. explicit wp_username + wp_app_password fields (recommended)
|
|
# 2. legacy username + app_password (single-credential mode)
|
|
# 3. None — media tools will surface a clear error at call time
|
|
wp_user = (config.get("wp_username") or "").strip()
|
|
wp_pw = (config.get("wp_app_password") or "").strip()
|
|
legacy_user = (config.get("username") or "").strip()
|
|
legacy_pw = (config.get("app_password") or "").strip()
|
|
# If consumer_key/consumer_secret are present, we're in WC-keys
|
|
# mode and the primary client cannot hit /wp/v2/*. We need
|
|
# explicit WP creds OR legacy app_password to enable media tools.
|
|
if wp_user and wp_pw:
|
|
self.wp_media_client: WordPressClient | None = WordPressClient(
|
|
site_url=config["url"], username=wp_user, app_password=wp_pw
|
|
)
|
|
elif (
|
|
legacy_user
|
|
and legacy_pw
|
|
and not (config.get("consumer_key") or config.get("consumer_secret"))
|
|
):
|
|
# Legacy app_password mode (no ck/cs at all) — same client
|
|
# works for both /wp/v2/* and /wc/v3/* because it uses an
|
|
# Application Password.
|
|
self.wp_media_client = self.client
|
|
else:
|
|
self.wp_media_client = None
|
|
|
|
# Initialize WooCommerce handlers
|
|
self.products = ProductsHandler(self.client)
|
|
self.orders = OrdersHandler(self.client)
|
|
self.customers = CustomersHandler(self.client)
|
|
self.coupons = CouponsHandler(self.client)
|
|
self.reports = ReportsHandler(self.client)
|
|
self.media_attach = MediaAttachHandler(self.client, wp_media_client=self.wp_media_client)
|
|
# F.X.fix-pass5 — expose generate_and_upload_image on WC sites
|
|
# too (was WP-only). The handler reads the per-site provider
|
|
# key resolver and uploads via wp_media_client (so WC sites
|
|
# with consumer_key/consumer_secret as primary still work as
|
|
# long as wp_username + wp_app_password are configured).
|
|
# When wp_media_client is None we still register the tool so
|
|
# the user sees a clear NO_PROVIDER_KEY / WP_CREDENTIALS_MISSING
|
|
# error rather than a missing-tool 404.
|
|
self.ai_media = AIMediaHandler(
|
|
self.wp_media_client or self.client,
|
|
user_id=config.get("user_id"),
|
|
site_id=config.get("site_id"),
|
|
# F.X.fix-pass6 — pass the primary WC client so
|
|
# _apply_metadata_and_attach can detect "attach_to_post
|
|
# is a WC product" and route featured-image set through
|
|
# /wc/v3/products/{id} instead of the WP /posts endpoint
|
|
# which 404s for products.
|
|
wc_client=self.client,
|
|
)
|
|
|
|
@staticmethod
|
|
def get_tool_specifications() -> list[dict[str, Any]]:
|
|
"""
|
|
Return all tool specifications for ToolGenerator.
|
|
|
|
This method is called by ToolGenerator to create unified tools
|
|
with site parameter routing.
|
|
|
|
Returns:
|
|
List of tool specification dictionaries (28 tools)
|
|
"""
|
|
specs = []
|
|
|
|
# Products (12 tools)
|
|
specs.extend(get_products_specs())
|
|
|
|
# Orders (5 tools)
|
|
specs.extend(get_orders_specs())
|
|
|
|
# Customers (4 tools)
|
|
specs.extend(get_customers_specs())
|
|
|
|
# Coupons (4 tools)
|
|
specs.extend(get_coupons_specs())
|
|
|
|
# Reports (3 tools)
|
|
specs.extend(get_reports_specs())
|
|
|
|
# F.5a.3: Media attachment (3 tools — attach_media_to_product,
|
|
# upload_and_attach_to_product, set_featured_image)
|
|
specs.extend(get_media_attach_specs())
|
|
|
|
# F.X.fix-pass5: AI image generation (1 tool — also surfaced on
|
|
# the WP plugin; same handler, same per-site provider key
|
|
# resolver). On a WC site this lets the operator chain
|
|
# generate → attach without two endpoints.
|
|
specs.extend(get_ai_media_specs())
|
|
|
|
return specs
|
|
|
|
async def health_check(self) -> dict[str, Any]:
|
|
"""
|
|
Check WooCommerce availability and health.
|
|
|
|
Returns:
|
|
Dict with health status and WooCommerce version
|
|
"""
|
|
try:
|
|
wc_status = await self.client.check_woocommerce()
|
|
if wc_status.get("available"):
|
|
return {
|
|
"healthy": True,
|
|
"message": "WooCommerce is available",
|
|
"version": wc_status.get("version", "unknown"),
|
|
"plugin_type": "woocommerce",
|
|
}
|
|
else:
|
|
return {
|
|
"healthy": False,
|
|
"message": "WooCommerce is not available on this site",
|
|
"plugin_type": "woocommerce",
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"healthy": False,
|
|
"message": f"Health check failed: {str(e)}",
|
|
"plugin_type": "woocommerce",
|
|
}
|
|
|
|
async def probe_credential_capabilities(self) -> dict[str, Any]:
|
|
"""F.7e — report what the WooCommerce consumer key grants.
|
|
|
|
Uses ``GET /wp-json/wc/v3/system_status`` which is readable by any
|
|
key with at least ``read`` permission and includes a
|
|
``security.rest_api_keys[]`` array where each entry carries a
|
|
``permissions`` field (``read`` / ``write`` / ``read_write``). We
|
|
match the caller's consumer_key against the ``truncated_key`` the
|
|
endpoint reports (last 7 chars) and derive the granted cap list.
|
|
|
|
Falls back gracefully on any network / shape failure — callers
|
|
still get the tools, they just don't get the early-warning badge.
|
|
"""
|
|
try:
|
|
status = await self.client.get("system_status", use_woocommerce=True)
|
|
except Exception as exc: # noqa: BLE001
|
|
return {
|
|
"probe_available": False,
|
|
"granted": [],
|
|
"source": "woocommerce_system_status",
|
|
"reason": f"system_status_unreachable: {exc}",
|
|
}
|
|
|
|
if not isinstance(status, dict):
|
|
return {
|
|
"probe_available": False,
|
|
"granted": [],
|
|
"source": "woocommerce_system_status",
|
|
"reason": "non_dict_response",
|
|
}
|
|
|
|
keys = ((status.get("security") or {}).get("rest_api_keys")) or []
|
|
consumer_key = self.config.get("consumer_key") or self.config.get("username") or ""
|
|
# WC's system_status truncates to last 7 chars by default.
|
|
my_tail = consumer_key[-7:] if consumer_key else ""
|
|
match = None
|
|
for entry in keys:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
tail = str(entry.get("truncated_key") or "")
|
|
if my_tail and tail == my_tail:
|
|
match = entry
|
|
break
|
|
|
|
if match is None:
|
|
# Fallback: if there's exactly one key listed OR the endpoint
|
|
# doesn't expose truncated_key on this WC version, fall back
|
|
# to the top entry so we still show *something* useful.
|
|
if len(keys) == 1 and isinstance(keys[0], dict):
|
|
match = keys[0]
|
|
|
|
# F.X.fix-pass5 — flag whether the site has WP App Password
|
|
# credentials configured. Media tools (and AI image with
|
|
# attach) need these on a WC site whose primary credential is
|
|
# consumer_key + secret. The prerequisites resolver in
|
|
# core/tool_access reads this to gate the WC media tool list.
|
|
wp_credentials_present = bool(
|
|
(self.config.get("wp_username") and self.config.get("wp_app_password"))
|
|
or (
|
|
self.config.get("username")
|
|
and self.config.get("app_password")
|
|
and not (self.config.get("consumer_key") or self.config.get("consumer_secret"))
|
|
)
|
|
)
|
|
|
|
if match is not None:
|
|
permission = str(match.get("permissions") or "").lower()
|
|
granted: list[str] = []
|
|
if permission in {"read", "read_write", "write"}:
|
|
granted.append("read_products")
|
|
granted.append("read_orders")
|
|
if permission in {"write", "read_write"}:
|
|
granted.append("write_products")
|
|
granted.append("write_orders")
|
|
|
|
return {
|
|
"probe_available": True,
|
|
"granted": sorted(granted),
|
|
"source": "woocommerce_system_status",
|
|
"permissions": permission,
|
|
"wp_credentials_present": wp_credentials_present,
|
|
}
|
|
|
|
# F.X.fix-pass3 — match is None (consumer key not listed). This
|
|
# happens when:
|
|
# * system_status omits truncated_key on the running WC build
|
|
# * the key was created via WP-CLI / a custom path
|
|
# * the key is shadowed by another key with the same tail
|
|
# Don't surface a false-negative "probe unavailable" (which the
|
|
# badge labelled with a misleading "install companion plugin"
|
|
# hint, since WC has no companion).
|
|
#
|
|
# F.X.fix-pass5 — STAY CONSERVATIVE. The previous pass probed
|
|
# ``GET /wc/v3/settings`` and upgraded to write+admin on 200,
|
|
# but that signal mixes two unrelated facts (WP user has
|
|
# ``manage_woocommerce`` capability AND key has any read perm)
|
|
# and was over-granting on read-only keys whose backing user
|
|
# was an admin. Result: tier-fit "Read + Write" stayed green
|
|
# for read-only keys. Now we report read-only and let the
|
|
# tier-fit warning fire correctly. Operators with read+write
|
|
# keys still see their tools work; the badge just says
|
|
# "Currently granted: read_products, read_orders" — a soft
|
|
# signal, not a block.
|
|
return {
|
|
"probe_available": True,
|
|
"granted": sorted({"read_products", "read_orders"}),
|
|
"source": "woocommerce_system_status_inferred",
|
|
"permissions": "inferred",
|
|
"probe_inferred": True,
|
|
"wp_credentials_present": wp_credentials_present,
|
|
}
|
|
|
|
# ========================================
|
|
# Method Delegation to Handlers
|
|
# ========================================
|
|
|
|
# === Products ===
|
|
async def list_products(self, **kwargs):
|
|
return await self.products.list_products(**kwargs)
|
|
|
|
async def get_product(self, **kwargs):
|
|
return await self.products.get_product(**kwargs)
|
|
|
|
async def create_product(self, **kwargs):
|
|
return await self.products.create_product(**kwargs)
|
|
|
|
async def update_product(self, **kwargs):
|
|
return await self.products.update_product(**kwargs)
|
|
|
|
async def delete_product(self, **kwargs):
|
|
return await self.products.delete_product(**kwargs)
|
|
|
|
async def list_product_categories(self, **kwargs):
|
|
return await self.products.list_product_categories(**kwargs)
|
|
|
|
async def create_product_category(self, **kwargs):
|
|
return await self.products.create_product_category(**kwargs)
|
|
|
|
async def list_product_tags(self, **kwargs):
|
|
return await self.products.list_product_tags(**kwargs)
|
|
|
|
async def list_product_attributes(self, **kwargs):
|
|
return await self.products.list_product_attributes(**kwargs)
|
|
|
|
async def create_product_attribute(self, **kwargs):
|
|
return await self.products.create_product_attribute(**kwargs)
|
|
|
|
async def list_product_variations(self, **kwargs):
|
|
return await self.products.list_product_variations(**kwargs)
|
|
|
|
async def create_product_variation(self, **kwargs):
|
|
return await self.products.create_product_variation(**kwargs)
|
|
|
|
# === Orders ===
|
|
async def list_orders(self, **kwargs):
|
|
return await self.orders.list_orders(**kwargs)
|
|
|
|
async def get_order(self, **kwargs):
|
|
return await self.orders.get_order(**kwargs)
|
|
|
|
async def create_order(self, **kwargs):
|
|
return await self.orders.create_order(**kwargs)
|
|
|
|
async def update_order_status(self, **kwargs):
|
|
return await self.orders.update_order_status(**kwargs)
|
|
|
|
async def delete_order(self, **kwargs):
|
|
return await self.orders.delete_order(**kwargs)
|
|
|
|
# === Customers ===
|
|
async def list_customers(self, **kwargs):
|
|
return await self.customers.list_customers(**kwargs)
|
|
|
|
async def get_customer(self, **kwargs):
|
|
return await self.customers.get_customer(**kwargs)
|
|
|
|
async def create_customer(self, **kwargs):
|
|
return await self.customers.create_customer(**kwargs)
|
|
|
|
async def update_customer(self, **kwargs):
|
|
return await self.customers.update_customer(**kwargs)
|
|
|
|
# === Coupons ===
|
|
async def list_coupons(self, **kwargs):
|
|
return await self.coupons.list_coupons(**kwargs)
|
|
|
|
async def create_coupon(self, **kwargs):
|
|
return await self.coupons.create_coupon(**kwargs)
|
|
|
|
async def update_coupon(self, **kwargs):
|
|
return await self.coupons.update_coupon(**kwargs)
|
|
|
|
async def delete_coupon(self, **kwargs):
|
|
return await self.coupons.delete_coupon(**kwargs)
|
|
|
|
# === Reports ===
|
|
async def get_sales_report(self, **kwargs):
|
|
return await self.reports.get_sales_report(**kwargs)
|
|
|
|
async def get_top_sellers(self, **kwargs):
|
|
return await self.reports.get_top_sellers(**kwargs)
|
|
|
|
async def get_customer_report(self, **kwargs):
|
|
return await self.reports.get_customer_report(**kwargs)
|
|
|
|
# === F.5a.3: Media attach ===
|
|
async def attach_media_to_product(self, **kwargs):
|
|
return await self.media_attach.attach_media_to_product(**kwargs)
|
|
|
|
async def upload_and_attach_to_product(self, **kwargs):
|
|
return await self.media_attach.upload_and_attach_to_product(**kwargs)
|
|
|
|
async def set_featured_image(self, **kwargs):
|
|
return await self.media_attach.set_featured_image(**kwargs)
|
|
|
|
# === F.X.fix-pass5: AI image (re-exported from WP handler) ===
|
|
async def generate_and_upload_image(self, **kwargs):
|
|
return await self.ai_media.generate_and_upload_image(**kwargs)
|