From 9836a67dc39e3ecdee9ac715c9cc521869cffe02 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Wed, 25 Feb 2026 09:27:54 -0700 Subject: [PATCH 1/5] [ADD] connector_onshape: Onshape PLM connector for Odoo New OCA-style connector module for bidirectional synchronization between Odoo and Onshape cloud CAD/PLM platform. Features: - HMAC and OAuth2 authentication with Onshape API - Document, element, and part discovery from Onshape workspace - Product binding with 4-strategy SKU matching (exact, extension strip, version strip, case-insensitive) - Assembly BOM import with component match scoring - Part number/description export from Odoo to Onshape metadata - Webhook receiver for real-time change notifications - Event listener for auto-export on product changes - Queue job integration for async processing with retry patterns - Cron jobs for scheduled background synchronization - Import wizard for guided document/product/BOM imports - Rate limit handling and API quota exhaustion detection (402) Models: onshape.backend, onshape.document, onshape.document.element, onshape.product.product, onshape.mrp.bom Test suite: 51 test methods covering backend, adapter, importers, exporters, mappers, binder, webhooks, listener, and wizard. Co-Authored-By: Claude Opus 4.6 --- connector_onshape/README.rst | 257 ++++++++ connector_onshape/__init__.py | 1 + connector_onshape/__manifest__.py | 37 ++ connector_onshape/components/__init__.py | 1 + connector_onshape/components/adapter.py | 327 ++++++++++ connector_onshape/components/binder.py | 69 ++ connector_onshape/components/core.py | 16 + connector_onshape/components/exporter.py | 106 +++ connector_onshape/components/importer.py | 525 +++++++++++++++ connector_onshape/components/listener.py | 43 ++ connector_onshape/components/mapper.py | 134 ++++ connector_onshape/controllers/__init__.py | 1 + connector_onshape/controllers/webhook.py | 185 ++++++ connector_onshape/data/ir_cron_data.xml | 40 ++ .../data/queue_job_channel_data.xml | 9 + .../data/queue_job_function_data.xml | 36 ++ connector_onshape/models/__init__.py | 9 + connector_onshape/models/mrp_bom.py | 24 + connector_onshape/models/onshape_backend.py | 193 ++++++ connector_onshape/models/onshape_document.py | 120 ++++ connector_onshape/models/onshape_mrp_bom.py | 79 +++ .../models/onshape_product_product.py | 109 ++++ connector_onshape/models/product_product.py | 34 + connector_onshape/models/product_template.py | 23 + connector_onshape/readme/CONFIGURE.rst | 107 +++ connector_onshape/readme/CONTRIBUTORS.rst | 1 + connector_onshape/readme/DESCRIPTION.rst | 20 + connector_onshape/readme/USAGE.rst | 47 ++ .../security/ir.model.access.csv | 12 + .../security/onshape_security.xml | 21 + connector_onshape/static/description/icon.png | Bin 0 -> 299 bytes connector_onshape/static/description/icon.svg | 5 + .../static/description/index.html | 610 ++++++++++++++++++ connector_onshape/tests/__init__.py | 11 + connector_onshape/tests/common.py | 168 +++++ connector_onshape/tests/test_adapter.py | 89 +++ connector_onshape/tests/test_backend.py | 99 +++ .../tests/test_export_product.py | 78 +++ connector_onshape/tests/test_import_bom.py | 63 ++ .../tests/test_import_product.py | 125 ++++ connector_onshape/tests/test_importer_flow.py | 139 ++++ connector_onshape/tests/test_listener.py | 47 ++ connector_onshape/tests/test_webhook.py | 91 +++ connector_onshape/tests/test_wizard.py | 63 ++ connector_onshape/views/mrp_bom_views.xml | 87 +++ .../views/onshape_backend_views.xml | 199 ++++++ .../views/onshape_document_views.xml | 143 ++++ .../views/onshape_product_views.xml | 132 ++++ .../views/product_template_views.xml | 72 +++ connector_onshape/wizards/__init__.py | 1 + .../wizards/onshape_import_wizard.py | 79 +++ .../wizards/onshape_import_wizard_views.xml | 37 ++ .../odoo/addons/connector_onshape | 1 + setup/connector_onshape/setup.py | 6 + 54 files changed, 4931 insertions(+) create mode 100644 connector_onshape/README.rst create mode 100644 connector_onshape/__init__.py create mode 100644 connector_onshape/__manifest__.py create mode 100644 connector_onshape/components/__init__.py create mode 100644 connector_onshape/components/adapter.py create mode 100644 connector_onshape/components/binder.py create mode 100644 connector_onshape/components/core.py create mode 100644 connector_onshape/components/exporter.py create mode 100644 connector_onshape/components/importer.py create mode 100644 connector_onshape/components/listener.py create mode 100644 connector_onshape/components/mapper.py create mode 100644 connector_onshape/controllers/__init__.py create mode 100644 connector_onshape/controllers/webhook.py create mode 100644 connector_onshape/data/ir_cron_data.xml create mode 100644 connector_onshape/data/queue_job_channel_data.xml create mode 100644 connector_onshape/data/queue_job_function_data.xml create mode 100644 connector_onshape/models/__init__.py create mode 100644 connector_onshape/models/mrp_bom.py create mode 100644 connector_onshape/models/onshape_backend.py create mode 100644 connector_onshape/models/onshape_document.py create mode 100644 connector_onshape/models/onshape_mrp_bom.py create mode 100644 connector_onshape/models/onshape_product_product.py create mode 100644 connector_onshape/models/product_product.py create mode 100644 connector_onshape/models/product_template.py create mode 100644 connector_onshape/readme/CONFIGURE.rst create mode 100644 connector_onshape/readme/CONTRIBUTORS.rst create mode 100644 connector_onshape/readme/DESCRIPTION.rst create mode 100644 connector_onshape/readme/USAGE.rst create mode 100644 connector_onshape/security/ir.model.access.csv create mode 100644 connector_onshape/security/onshape_security.xml create mode 100644 connector_onshape/static/description/icon.png create mode 100644 connector_onshape/static/description/icon.svg create mode 100644 connector_onshape/static/description/index.html create mode 100644 connector_onshape/tests/__init__.py create mode 100644 connector_onshape/tests/common.py create mode 100644 connector_onshape/tests/test_adapter.py create mode 100644 connector_onshape/tests/test_backend.py create mode 100644 connector_onshape/tests/test_export_product.py create mode 100644 connector_onshape/tests/test_import_bom.py create mode 100644 connector_onshape/tests/test_import_product.py create mode 100644 connector_onshape/tests/test_importer_flow.py create mode 100644 connector_onshape/tests/test_listener.py create mode 100644 connector_onshape/tests/test_webhook.py create mode 100644 connector_onshape/tests/test_wizard.py create mode 100644 connector_onshape/views/mrp_bom_views.xml create mode 100644 connector_onshape/views/onshape_backend_views.xml create mode 100644 connector_onshape/views/onshape_document_views.xml create mode 100644 connector_onshape/views/onshape_product_views.xml create mode 100644 connector_onshape/views/product_template_views.xml create mode 100644 connector_onshape/wizards/__init__.py create mode 100644 connector_onshape/wizards/onshape_import_wizard.py create mode 100644 connector_onshape/wizards/onshape_import_wizard_views.xml create mode 120000 setup/connector_onshape/odoo/addons/connector_onshape create mode 100644 setup/connector_onshape/setup.py diff --git a/connector_onshape/README.rst b/connector_onshape/README.rst new file mode 100644 index 000000000..7785f87ea --- /dev/null +++ b/connector_onshape/README.rst @@ -0,0 +1,257 @@ +================= +Onshape Connector +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a5b097e306fb250e6bb558ec033e08f2af79ffbb0c1f3ef97cbefb94e8916081 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fconnector-lightgray.png?logo=github + :target: https://github.com/OCA/connector/tree/16.0/connector_onshape + :alt: OCA/connector +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/connector-16-0/connector-16-0-connector_onshape + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/connector&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides bidirectional synchronization between Odoo and +`Onshape `_ cloud CAD/PLM platform. + +It synchronizes: + +* **Documents**: Import Onshape documents and their elements (part studios, + assemblies, drawings). +* **Products**: Bind Onshape parts to Odoo products using a 4-strategy + SKU matching algorithm (exact filename, part number, McMaster catalog, + case-insensitive). +* **Bills of Materials**: Import Onshape assembly BOMs as ``mrp.bom`` records + with component match scoring. +* **Metadata Export**: Push Odoo product SKUs and names back to Onshape + part metadata (Part Number, Description fields). +* **Webhooks**: Receive real-time notifications from Onshape for metadata + changes, workflow transitions, and revision creation. + +The module uses the OCA Connector framework with queue_job for asynchronous +processing and supports both HMAC (API Key) and OAuth2 (App Store) +authentication modes. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Onshape Developer Portal Setup +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Before configuring the Odoo backend, you need API credentials from Onshape. + +**HMAC API Keys (quickest to start):** + +1. Sign in at https://cad.onshape.com +2. Go to your **User Menu** (top-right) > **My Account** > **API keys** + (or visit https://dev-portal.onshape.com/keys directly) +3. Click **Create new API key** +4. Give it a name (e.g. ``Odoo Connector``) and select scopes: + + * ``OAuth2Read`` — read documents, parts, assemblies, metadata + * ``OAuth2Write`` — write metadata (Part Number, Description) + * ``OAuth2Delete`` — only if you need webhook management + +5. Copy the **Access key** and **Secret key** — the secret is shown only once. + +**Finding your Team / Company ID:** + +1. Go to https://cad.onshape.com +2. Click **Teams** in the left sidebar +3. Click on your team — the URL will show the team ID: + ``https://cad.onshape.com/team/`` + +**OAuth2 App Store (recommended for production):** + +HMAC keys and private OAuth2 apps count against an annual API quota +(~10,000 calls/user/year for Enterprise). Only **publicly listed App Store +apps** are exempt. To set up OAuth2: + +1. Go to https://dev-portal.onshape.com > **OAuth applications** +2. Click **Create new OAuth application** +3. Fill in: + + * **Name**: Your app name (e.g. ``Kencove Odoo Connector``) + * **Primary Format**: ``com.yourcompany.odoo-connector`` (cannot change later) + * **Redirect URLs**: ``https://your-odoo.com/connector_onshape/oauth/callback`` + * **OAuth Scopes**: ``OAuth2Read``, ``OAuth2Write`` + +4. For quota exemption, submit the app for App Store review + by emailing ``onshape-developer-relations@ptc.com`` + +Odoo Backend Configuration +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Install the ``connector_onshape`` module. +2. Go to **Onshape > Configuration > Backends** and create a new backend. +3. Fill in the connection details: + + * **Base URL**: ``https://cad.onshape.com`` (default) + * **Authentication Mode**: HMAC or OAuth2 + * **API Key / Secret**: From step above (HMAC mode) + * **Team / Company ID**: From step above + +4. Click **Check Credentials** — should show a green success notification. +5. Click **Activate** to enable the backend. + +Import Settings +~~~~~~~~~~~~~~~ + +* **Auto-create Products**: When enabled, creates new Odoo products for + Onshape parts that don't match any existing SKU. When disabled, unmatched + parts are skipped (no binding created). +* **Default Product Category**: Category assigned to auto-created products. +* **Import Products Since**: Only import parts modified after this date + (for incremental sync). + +Webhooks (Real-Time Sync) +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Webhooks push Onshape changes to Odoo in real time instead of waiting +for the next scheduled sync. + +1. In Odoo, note your backend ID (visible in the URL when viewing the backend + form, e.g. ``/web#id=1&model=onshape.backend``). + +2. Your webhook URL is: + ``https://your-odoo-instance.com/connector_onshape/webhook/`` + +3. Generate a webhook secret (any random string, e.g. ``openssl rand -hex 32``) + and enter it in the **Webhook Secret** field on the backend form. + +4. Register the webhook in Onshape. You can do this via the Onshape API + or by clicking the **Register Webhook** button (if available) on the backend. + The module listens for these events: + + * ``onshape.model.lifecycle.metadata`` — re-imports part metadata + * ``onshape.workflow.transition`` — updates lifecycle state + * ``onshape.revision.created`` — marks parts as released + * ``onshape.model.lifecycle.createversion`` — logs version creation + +5. Ensure your Odoo instance is reachable from the internet (Onshape must + be able to POST to the webhook URL). + +Scheduled Sync (Cron Jobs) +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Three cron jobs are created (disabled by default): + +* **Onshape: Import Documents** — every 6 hours +* **Onshape: Import Products** — every 6 hours +* **Onshape: Import BOMs** — every 12 hours + +Enable them in **Settings > Technical > Automation > Scheduled Actions** +when you're ready for automatic background synchronization. + +Usage +===== + +Import Documents +~~~~~~~~~~~~~~~~ + +Click **Import Documents** on the backend form to fetch all Onshape +documents from your team. Documents are created with their elements +(part studios, assemblies, drawings). + +Import Products +~~~~~~~~~~~~~~~ + +Click **Import Products** to scan all part studio elements and create +product bindings. The module uses a 4-strategy matching algorithm: + +1. **Exact filename**: Part name matches an Odoo product SKU +2. **Part number**: Onshape Part Number metadata matches an Odoo SKU +3. **McMaster catalog**: Extracted catalog numbers (e.g., 90185A632) match +4. **Case-insensitive**: Fallback case-insensitive match + +Unmatched parts will be auto-created as products if configured. + +Import BOMs +~~~~~~~~~~~ + +Click **Import BOMs** to fetch assembly BOMs from Onshape and create +``mrp.bom`` records. Each BOM includes a **match score** indicating +what percentage of Onshape components were matched to Odoo products. + +Export Part Numbers +~~~~~~~~~~~~~~~~~~~ + +Click **Export Part Numbers** to push Odoo product SKUs and names +back to Onshape. This writes the ``default_code`` as "Part Number" +and ``name`` as "Description" in Onshape metadata. + +Automatic Export +~~~~~~~~~~~~~~~~ + +When a product's ``default_code`` or ``name`` is changed in Odoo, +a background job is automatically queued to export the update to +Onshape (if the product is bound to an Onshape part). + +Import Wizard +~~~~~~~~~~~~~ + +Use **Onshape > Onshape Data > Import from Onshape** for a guided +import process with options for documents-only, documents+products, +or full sync including BOMs. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Kencove Farm Fence Supplies + +Contributors +~~~~~~~~~~~~ + +* Kencove Farm Fence Supplies + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/connector `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/connector_onshape/__init__.py b/connector_onshape/__init__.py new file mode 100644 index 000000000..a11487d3e --- /dev/null +++ b/connector_onshape/__init__.py @@ -0,0 +1 @@ +from . import components, controllers, models, wizards diff --git a/connector_onshape/__manifest__.py b/connector_onshape/__manifest__.py new file mode 100644 index 000000000..a99f4d282 --- /dev/null +++ b/connector_onshape/__manifest__.py @@ -0,0 +1,37 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Onshape Connector", + "version": "16.0.1.0.0", + "category": "Connector", + "summary": "Synchronize products and BOMs with Onshape PLM", + "author": "Kencove Farm Fence Supplies, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/connector", + "license": "AGPL-3", + "depends": [ + "connector", + "component", + "component_event", + "queue_job", + "product", + "mrp", + ], + "external_dependencies": { + "python": [], + }, + "data": [ + "security/onshape_security.xml", + "security/ir.model.access.csv", + "data/queue_job_channel_data.xml", + "data/queue_job_function_data.xml", + "data/ir_cron_data.xml", + "views/onshape_backend_views.xml", + "views/onshape_document_views.xml", + "views/onshape_product_views.xml", + "views/product_template_views.xml", + "views/mrp_bom_views.xml", + "wizards/onshape_import_wizard_views.xml", + ], + "installable": True, + "application": True, +} diff --git a/connector_onshape/components/__init__.py b/connector_onshape/components/__init__.py new file mode 100644 index 000000000..1d39f9ae5 --- /dev/null +++ b/connector_onshape/components/__init__.py @@ -0,0 +1 @@ +from . import adapter, binder, core, exporter, importer, listener, mapper diff --git a/connector_onshape/components/adapter.py b/connector_onshape/components/adapter.py new file mode 100644 index 000000000..20d9fe1e8 --- /dev/null +++ b/connector_onshape/components/adapter.py @@ -0,0 +1,327 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import hashlib +import hmac +import json +import logging +import random +import string +import time +from datetime import datetime +from urllib.parse import urlencode + +import requests + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + +MAX_RETRIES = 3 +RETRY_BACKOFF = 5 + + +class OnshapeAdapter(Component): + """API adapter for Onshape REST API. + + Supports HMAC authentication (ported from onshape_utils.py) + and OAuth2 bearer tokens (future). + + Handles rate limiting (429), quota exhaustion (402), and retries. + """ + + _name = "onshape.adapter" + _inherit = "onshape.base" + _usage = "backend.adapter" + + # --- Authentication --- + + def _get_backend(self): + return self.collection + + def _canonical_query(self, params): + if not params: + return "" + return urlencode(sorted(params.items()), doseq=True) + + def _hmac_headers(self, method, path, query_params=None, content_type=""): + """Generate HMAC auth headers. + + Ported from onshape_utils.py lines 52-94. + Signing string: method, nonce, date, content_type, path, query + All lowercased, HMAC-SHA256 signed, Base64 encoded. + """ + backend = self._get_backend() + method = method.upper() + date = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT") + nonce = "".join( + random.choice(string.ascii_letters + string.digits) for _ in range(25) + ) + canonical_query = self._canonical_query(query_params) + + string_to_sign = ( + method + + "\n" + + nonce + + "\n" + + date + + "\n" + + content_type + + "\n" + + path + + "\n" + + canonical_query + + "\n" + ).lower() + + sig_bytes = hmac.new( + backend.api_secret.encode("utf-8"), + string_to_sign.encode("utf-8"), + digestmod=hashlib.sha256, + ).digest() + signature = base64.b64encode(sig_bytes).decode("utf-8") + + headers = { + "Date": date, + "Authorization": "On %s:HmacSHA256:%s" % (backend.api_key, signature), + "On-Nonce": nonce, + "Accept": "application/json", + } + if content_type: + headers["Content-Type"] = content_type + return headers + + def _oauth2_headers(self, content_type=""): + backend = self._get_backend() + token_data = json.loads(backend.oauth2_token or "{}") + access_token = token_data.get("access_token", "") + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + } + if content_type: + headers["Content-Type"] = content_type + return headers + + def _get_auth_headers(self, method, path, query_params=None, content_type=""): + backend = self._get_backend() + if backend.auth_mode == "oauth2": + return self._oauth2_headers(content_type=content_type) + return self._hmac_headers( + method, + path, + query_params=query_params, + content_type=content_type, + ) + + # --- HTTP request with retry --- + + def _request(self, method, path, query_params=None, json_body=None, raw=False): + """Make an authenticated API request with retry and rate-limit handling. + + Ported from enrich_onshape_metadata.py lines 147-176. + """ + backend = self._get_backend() + content_type = "application/json" if json_body else "" + url = f"{backend.base_url}{path}" + last_resp = None + + for attempt in range(MAX_RETRIES): + headers = self._get_auth_headers( + method, + path, + query_params=query_params, + content_type=content_type, + ) + try: + resp = requests.request( + method, + url, + headers=headers, + params=query_params, + json=json_body, + timeout=30, + ) + last_resp = resp + + # Track rate limit + remaining = resp.headers.get("X-Rate-Limit-Remaining") + if remaining is not None: + try: + remaining_int = int(remaining) + if remaining_int < 10: + _logger.warning( + "Onshape rate limit low: %s remaining", + remaining_int, + ) + except ValueError: + _logger.debug("Non-integer rate limit header: %s", remaining) + + if resp.status_code == 402: + _logger.error( + "Onshape API quota exhausted (402). " + "Consider switching to OAuth2." + ) + raise OnshapeQuotaError( + "Onshape API quota exhausted. " + "Switch to OAuth2 App Store authentication." + ) + + if resp.status_code == 429: + retry_after = resp.headers.get("Retry-After") + wait = ( + int(retry_after) + if retry_after + else RETRY_BACKOFF * (attempt + 1) + ) + _logger.warning("Onshape rate limited (429), waiting %ss", wait) + time.sleep(wait) + continue + + if resp.status_code >= 500: + wait = RETRY_BACKOFF * (attempt + 1) + _logger.warning( + "Onshape server error %s, retry in %ss", + resp.status_code, + wait, + ) + time.sleep(wait) + continue + + if raw: + return resp + + resp.raise_for_status() + if resp.status_code == 204: + return {} + return resp.json() + + except requests.exceptions.RequestException: + if attempt < MAX_RETRIES - 1: + time.sleep(RETRY_BACKOFF) + continue + raise + + if raw: + return last_resp + if last_resp is not None: + last_resp.raise_for_status() + return {} + + # --- Credential check --- + + def check_credentials(self): + try: + resp = self._request( + "GET", + "/api/v6/documents", + query_params={"limit": 1}, + raw=True, + ) + if resp.status_code == 200: + return True, "Credentials verified (documents endpoint OK)." + if resp.status_code == 401: + return False, "Unauthorized (401). Check API key/secret." + return ( + False, + f"Unexpected status {resp.status_code}: " f"{resp.text[:200]}", + ) + except OnshapeQuotaError as e: + return False, str(e) + except Exception as e: + return False, f"Connection error: {e}" + + # --- Document endpoints --- + + def search_documents(self, owner_id=None, offset=0, limit=20): + params = {"offset": offset, "limit": limit, "sortColumn": "name"} + if owner_id: + params["owner"] = owner_id + params["ownerType"] = 1 # team + return self._request("GET", "/api/v6/documents", query_params=params) + + def read_document(self, document_id): + return self._request("GET", f"/api/v6/documents/{document_id}") + + def read_document_elements(self, document_id, workspace_id): + return self._request( + "GET", + f"/api/v6/documents/d/{document_id}/w/{workspace_id}/elements", + ) + + # --- Part / Metadata endpoints --- + + def read_parts(self, document_id, workspace_id, element_id): + return self._request( + "GET", + f"/api/v6/parts/d/{document_id}/w/{workspace_id}" f"/e/{element_id}", + ) + + def read_part_metadata(self, document_id, workspace_id, element_id): + return self._request( + "GET", + f"/api/v6/metadata/d/{document_id}/w/{workspace_id}" f"/e/{element_id}", + ) + + def write_part_metadata(self, document_id, workspace_id, element_id, items): + """Write metadata to parts. + + Ported from enrich_onshape_metadata.py lines 201-246. + ``items`` is a list of dicts with 'href' and 'properties' keys. + """ + return self._request( + "POST", + f"/api/v6/metadata/d/{document_id}/w/{workspace_id}" f"/e/{element_id}", + json_body={"items": items}, + ) + + # --- Assembly BOM endpoint --- + + def read_assembly_bom(self, document_id, workspace_id, element_id): + return self._request( + "GET", + f"/api/v6/assemblies/d/{document_id}/w/{workspace_id}" + f"/e/{element_id}/bom", + ) + + # --- Thumbnail --- + + def read_thumbnail(self, document_id, size="300x300"): + resp = self._request( + "GET", + f"/api/v6/thumbnails/d/{document_id}/s/{size}", + raw=True, + ) + if resp.status_code == 200: + return base64.b64encode(resp.content) + return None + + # --- Webhooks --- + + def register_webhook(self, url, events=None): + backend = self._get_backend() + if events is None: + events = [ + "onshape.model.lifecycle.metadata", + "onshape.workflow.transition", + "onshape.revision.created", + "onshape.model.lifecycle.createversion", + ] + body = { + "url": url, + "events": events, + } + if backend.team_id: + body["filter"] = json.dumps({"teamId": backend.team_id}) + return self._request("POST", "/api/v6/webhooks", json_body=body) + + def list_webhooks(self): + return self._request("GET", "/api/v6/webhooks") + + def delete_webhook(self, webhook_id): + return self._request("DELETE", f"/api/v6/webhooks/{webhook_id}") + + +class OnshapeQuotaError(Exception): + """Raised when Onshape returns 402 (quota exhausted).""" diff --git a/connector_onshape/components/binder.py b/connector_onshape/components/binder.py new file mode 100644 index 000000000..87f718757 --- /dev/null +++ b/connector_onshape/components/binder.py @@ -0,0 +1,69 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class OnshapeBinder(Component): + """Binder for Onshape bindings. + + Handles compound external IDs (e.g. doc_id/elem_id/part_id). + """ + + _name = "onshape.binder" + _inherit = ["onshape.base", "base.binder"] + _usage = "binder" + _external_field = "external_id" + _apply_on = [ + "onshape.product.product", + "onshape.mrp.bom", + ] + + def to_internal(self, external_id, unwrap=False): + bindings = self.model.search( + [ + ("external_id", "=", external_id), + ("backend_id", "=", self.backend_record.id), + ], + limit=1, + ) + if not bindings: + return self.model.browse() + if unwrap: + return bindings.odoo_id + return bindings + + def to_external(self, binding, wrap=False): + if wrap: + binding = self.model.search( + [ + ("odoo_id", "=", binding.id), + ("backend_id", "=", self.backend_record.id), + ], + limit=1, + ) + if not binding: + return None + return binding.external_id + + def bind(self, external_id, binding): + binding.write({"external_id": external_id}) + + @staticmethod + def make_compound_id(document_id, element_id, part_id=None): + if part_id: + return f"{document_id}/{element_id}/{part_id}" + return f"{document_id}/{element_id}" + + @staticmethod + def split_compound_id(external_id): + parts = external_id.split("/") + if len(parts) == 3: + return { + "document_id": parts[0], + "element_id": parts[1], + "part_id": parts[2], + } + if len(parts) == 2: + return {"document_id": parts[0], "element_id": parts[1]} + return {"document_id": external_id} diff --git a/connector_onshape/components/core.py b/connector_onshape/components/core.py new file mode 100644 index 000000000..34fd2fb2c --- /dev/null +++ b/connector_onshape/components/core.py @@ -0,0 +1,16 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class OnshapeBaseComponent(AbstractComponent): + """Base component for Onshape connector. + + All Onshape components inherit from this to share the common + _collection name. + """ + + _name = "onshape.base" + _inherit = "base.connector" + _collection = "onshape.backend" diff --git a/connector_onshape/components/exporter.py b/connector_onshape/components/exporter.py new file mode 100644 index 000000000..7c33b1de6 --- /dev/null +++ b/connector_onshape/components/exporter.py @@ -0,0 +1,106 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import fields + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class OnshapeProductExporter(Component): + """Export product data (Part Number, Description) to Onshape. + + Ported from enrich_onshape_metadata.py:update_part_properties(). + Writes the Odoo default_code as Onshape "Part Number" and + product name as Onshape "Description". + """ + + _name = "onshape.product.exporter" + _inherit = "onshape.base" + _usage = "record.exporter" + _apply_on = "onshape.product.product" + + def run(self, binding): + adapter = self.component(usage="backend.adapter") + mapper = self.component(usage="export.mapper") + + doc = binding.onshape_document_id + if not doc: + _logger.warning("Binding %s has no document, skipping export", binding.id) + return + + ws_id = doc.onshape_default_workspace_id + elem_id = binding.onshape_element_id + if not ws_id or not elem_id: + _logger.warning( + "Binding %s missing workspace/element, skipping export", + binding.id, + ) + return + + # Get current metadata to find property IDs + meta = adapter.read_part_metadata( + doc.onshape_document_id, + ws_id, + elem_id, + ) + if not meta: + _logger.warning("Could not fetch metadata for binding %s", binding.id) + return + + export_values = mapper.map_record(binding) + part_number = export_values.get("part_number") + description = export_values.get("description") + + items = meta.get("items", []) + if not items: + return + + for part_item in items: + # Optionally filter by partId + if ( + binding.onshape_part_id + and part_item.get("partId") != binding.onshape_part_id + ): + continue + + properties_update = [] + for prop in part_item.get("properties", []): + prop_name = prop.get("name", "") + if prop_name == "Part Number" and part_number: + properties_update.append( + { + "propertyId": prop["propertyId"], + "value": part_number, + } + ) + elif prop_name == "Description" and description: + properties_update.append( + { + "propertyId": prop["propertyId"], + "value": description, + } + ) + + if properties_update: + adapter.write_part_metadata( + doc.onshape_document_id, + ws_id, + elem_id, + [ + { + "href": part_item.get("href", ""), + "properties": properties_update, + } + ], + ) + + binding.write({"sync_date": fields.Datetime.now()}) + _logger.info( + "Exported product data for binding %s (SKU: %s)", + binding.id, + part_number, + ) diff --git a/connector_onshape/components/importer.py b/connector_onshape/components/importer.py new file mode 100644 index 000000000..05941d66f --- /dev/null +++ b/connector_onshape/components/importer.py @@ -0,0 +1,525 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import hashlib +import json +import logging + +from odoo import fields + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class OnshapeDocumentBatchImporter(Component): + """Batch importer for Onshape documents. + + Lists all documents from the Onshape team and queues + individual record imports via queue_job. + """ + + _name = "onshape.document.batch.importer" + _inherit = "onshape.base" + _usage = "batch.importer" + _apply_on = "onshape.document" + + def run(self, backend, **kwargs): + adapter = self.component(usage="backend.adapter") + offset = 0 + limit = 50 + total_queued = 0 + + while True: + result = adapter.search_documents( + owner_id=backend.team_id, + offset=offset, + limit=limit, + ) + items = result.get("items", []) + if not items: + break + + for doc_data in items: + doc_id = doc_data.get("id") + if not doc_id: + continue + existing = self.env["onshape.document"].search( + [ + ("backend_id", "=", backend.id), + ("onshape_document_id", "=", doc_id), + ], + limit=1, + ) + if existing: + # Update name/timestamps + vals = {"name": doc_data.get("name", existing.name)} + modified = doc_data.get("modifiedAt") + if modified: + vals["modified_at"] = modified + existing.write(vals) + else: + self._import_document(backend, doc_data) + total_queued += 1 + + if len(items) < limit: + break + offset += limit + + _logger.info( + "Onshape document batch import: processed %d documents", + total_queued, + ) + + def _import_document(self, backend, doc_data): + """Create an onshape.document and import its elements.""" + default_ws = doc_data.get("defaultWorkspace") or {} + workspace_id = ( + default_ws.get("id") + or default_ws.get("workspaceId") + or doc_data.get("defaultWorkspaceId", "") + ) + owner_data = doc_data.get("owner") or {} + + vals = { + "backend_id": backend.id, + "name": doc_data.get("name", "Untitled"), + "onshape_document_id": doc_data["id"], + "onshape_default_workspace_id": workspace_id, + "owner": owner_data.get("name", ""), + "created_at": doc_data.get("createdAt"), + "modified_at": doc_data.get("modifiedAt"), + } + document = self.env["onshape.document"].create(vals) + + # Import elements + if workspace_id: + self._import_elements(backend, document, workspace_id) + + return document + + def _import_elements(self, backend, document, workspace_id): + adapter = self.component(usage="backend.adapter") + try: + elements = adapter.read_document_elements( + document.onshape_document_id, + workspace_id, + ) + except Exception: + _logger.warning( + "Could not fetch elements for document %s", + document.onshape_document_id, + ) + return + + if not isinstance(elements, list): + return + + type_map = { + "PARTSTUDIO": "partstudio", + "ASSEMBLY": "assembly", + "DRAWING": "drawing", + "BLOB": "blob", + "APPLICATION": "application", + } + + has_assembly = False + has_partstudio = False + + for elem in elements: + elem_type_raw = elem.get("elementType", "").upper() + elem_type = type_map.get(elem_type_raw) + if elem_type == "assembly": + has_assembly = True + elif elem_type == "partstudio": + has_partstudio = True + + self.env["onshape.document.element"].create( + { + "document_id": document.id, + "name": elem.get("name", ""), + "onshape_element_id": elem.get("id", ""), + "element_type": elem_type, + "microversion_id": elem.get("microversionId", ""), + } + ) + + # Infer document type + if has_assembly: + document.document_type = "assembly" + elif has_partstudio: + document.document_type = "part" + + +class OnshapeProductBatchImporter(Component): + """Batch importer for Onshape product bindings. + + Iterates over all documents with part studio elements and + imports parts as product bindings. + """ + + _name = "onshape.product.batch.importer" + _inherit = "onshape.base" + _usage = "batch.importer" + _apply_on = "onshape.product.product" + + def run(self, backend, **kwargs): + documents = self.env["onshape.document"].search( + [("backend_id", "=", backend.id)] + ) + adapter = self.component(usage="backend.adapter") + total = 0 + + for doc in documents: + ws_id = doc.onshape_default_workspace_id + if not ws_id: + continue + + part_studio_elements = doc.element_ids.filtered( + lambda e: e.element_type == "partstudio" + ) + for elem in part_studio_elements: + try: + parts_data = adapter.read_parts( + doc.onshape_document_id, + ws_id, + elem.onshape_element_id, + ) + except Exception: + _logger.warning( + "Could not fetch parts for %s/%s", + doc.onshape_document_id, + elem.onshape_element_id, + ) + continue + + if not isinstance(parts_data, list): + continue + + for part in parts_data: + part_id = part.get("partId", "") + if not part_id: + continue + + record_importer = self.component( + usage="record.importer", + model_name="onshape.product.product", + ) + record_importer.run( + backend=backend, + document=doc, + element=elem, + part_data=part, + ) + total += 1 + + _logger.info("Onshape product batch import: processed %d parts", total) + + +class OnshapeProductRecordImporter(Component): + """Record importer for a single Onshape part → product binding.""" + + _name = "onshape.product.record.importer" + _inherit = "onshape.base" + _usage = "record.importer" + _apply_on = "onshape.product.product" + + def run(self, backend, document, element, part_data): + binder = self.component(usage="binder") + mapper = self.component(usage="import.mapper") + + part_id = part_data.get("partId", "") + external_id = binder.make_compound_id( + document.onshape_document_id, + element.onshape_element_id, + part_id, + ) + + existing = binder.to_internal(external_id) + if existing: + # Update existing binding + vals = mapper.map_record( + part_data, + document=document, + element=element, + ) + update_vals = { + k: v + for k, v in vals.items() + if k + not in ( + "odoo_id", + "backend_id", + "external_id", + "match_type", + ) + } + update_vals["sync_date"] = fields.Datetime.now() + existing.with_context(connector_no_export=True).write(update_vals) + return existing + + # New binding + vals = mapper.map_record( + part_data, + document=document, + element=element, + ) + vals.update( + { + "backend_id": backend.id, + "external_id": external_id, + "onshape_document_id": document.id, + "onshape_element_id": element.onshape_element_id, + "onshape_part_id": part_id, + "sync_date": fields.Datetime.now(), + } + ) + + # Find or create Odoo product + odoo_product = vals.pop("odoo_id", None) + if not odoo_product: + odoo_product = self._match_or_create_product(backend, part_data, vals) + if not odoo_product: + _logger.debug( + "No match and auto_create disabled, skipping part %s", + part_data.get("name", ""), + ) + return None + vals["odoo_id"] = odoo_product.id + + binding = self.env["onshape.product.product"].create(vals) + return binding + + def _match_or_create_product(self, backend, part_data, vals): + """Try to match to an existing product, or create a new one.""" + mapper = self.component(usage="import.mapper") + product, match_type = mapper.match_product(part_data) + + if product: + vals["match_type"] = match_type + return product + + if not backend.auto_create_products: + return None + + # Auto-create product + part_name = part_data.get("name", "Unknown Part") + categ = backend.default_product_category_id + product = self.env["product.product"].create( + { + "name": part_name, + "type": "product", + "categ_id": categ.id if categ else False, + } + ) + vals["match_type"] = "auto_created" + return product + + +class OnshapeBomBatchImporter(Component): + """Batch importer for Onshape assembly BOMs.""" + + _name = "onshape.bom.batch.importer" + _inherit = "onshape.base" + _usage = "batch.importer" + _apply_on = "onshape.mrp.bom" + + def run(self, backend, **kwargs): + documents = self.env["onshape.document"].search( + [ + ("backend_id", "=", backend.id), + ("document_type", "=", "assembly"), + ] + ) + total = 0 + + for doc in documents: + ws_id = doc.onshape_default_workspace_id + if not ws_id: + continue + + assembly_elements = doc.element_ids.filtered( + lambda e: e.element_type == "assembly" + ) + for elem in assembly_elements: + record_importer = self.component( + usage="record.importer", + model_name="onshape.mrp.bom", + ) + record_importer.run( + backend=backend, + document=doc, + element=elem, + ) + total += 1 + + _logger.info("Onshape BOM batch import: processed %d assemblies", total) + + +class OnshapeBomRecordImporter(Component): + """Record importer for a single Onshape assembly → mrp.bom binding.""" + + _name = "onshape.bom.record.importer" + _inherit = "onshape.base" + _usage = "record.importer" + _apply_on = "onshape.mrp.bom" + + def run(self, backend, document, element): + adapter = self.component(usage="backend.adapter") + binder = self.component(usage="binder") + + external_id = binder.make_compound_id( + document.onshape_document_id, + element.onshape_element_id, + ) + + # Fetch BOM from Onshape + try: + bom_data = adapter.read_assembly_bom( + document.onshape_document_id, + document.onshape_default_workspace_id, + element.onshape_element_id, + ) + except Exception: + _logger.warning( + "Could not fetch BOM for %s/%s", + document.onshape_document_id, + element.onshape_element_id, + ) + return + + bom_items = bom_data.get("bomTable", {}).get("items", []) + if not bom_items: + return + + # Compute BOM hash for change detection + bom_hash = hashlib.md5( + json.dumps(bom_items, sort_keys=True).encode() + ).hexdigest() + + existing = binder.to_internal(external_id) + if existing and existing.last_bom_hash == bom_hash: + _logger.debug("BOM %s unchanged, skipping", external_id) + return existing + + # Find or create the parent product binding + parent_binding = self._find_parent_product(backend, document) + if not parent_binding: + _logger.warning( + "No parent product for assembly %s, skipping BOM", + document.name, + ) + return + + # Build BOM lines + bom_lines = self._build_bom_lines(backend, bom_items) + + if existing: + # Update existing BOM + existing.odoo_id.bom_line_ids.unlink() + existing.odoo_id.write({"bom_line_ids": bom_lines}) + existing.write( + { + "last_bom_hash": bom_hash, + "sync_date": fields.Datetime.now(), + "match_score": self._compute_match_score(bom_items, bom_lines), + } + ) + return existing + + # Create new BOM + binding + bom_vals = { + "product_tmpl_id": parent_binding.odoo_id.product_tmpl_id.id, + "product_id": parent_binding.odoo_id.id, + "type": "normal", + "bom_line_ids": bom_lines, + } + bom = self.env["mrp.bom"].create(bom_vals) + + binding = self.env["onshape.mrp.bom"].create( + { + "odoo_id": bom.id, + "backend_id": backend.id, + "external_id": external_id, + "onshape_document_id": document.id, + "onshape_element_id": element.onshape_element_id, + "last_bom_hash": bom_hash, + "sync_date": fields.Datetime.now(), + "match_score": self._compute_match_score(bom_items, bom_lines), + } + ) + return binding + + def _find_parent_product(self, backend, document): + bindings = self.env["onshape.product.product"].search( + [ + ("backend_id", "=", backend.id), + ("onshape_document_id", "=", document.id), + ], + limit=1, + ) + return bindings + + def _build_bom_lines(self, backend, bom_items): + lines = [] + for item in bom_items: + item_qty = item.get("quantity", 1) + + # Try to find a matching product binding by part name + product = self._find_component_product(backend, item) + if not product: + continue + + lines.append( + ( + 0, + 0, + { + "product_id": product.id, + "product_qty": item_qty, + }, + ) + ) + return lines + + def _find_component_product(self, backend, bom_item): + """Find an Odoo product matching a BOM component item.""" + item_name = bom_item.get("name", "") + part_number = bom_item.get("partNumber", "") + + # Try by part number (SKU) + if part_number: + product = self.env["product.product"].search( + [("default_code", "=", part_number)], limit=1 + ) + if product: + return product + + # Try by name + if item_name: + product = self.env["product.product"].search( + [("default_code", "=", item_name)], limit=1 + ) + if product: + return product + + # Try binding lookup + if part_number: + binding = self.env["onshape.product.product"].search( + [ + ("backend_id", "=", backend.id), + ("onshape_part_number", "=", part_number), + ], + limit=1, + ) + if binding: + return binding.odoo_id + + return None + + def _compute_match_score(self, bom_items, bom_lines): + total = len(bom_items) + if total == 0: + return 0.0 + matched = len(bom_lines) + return round(matched / total, 3) diff --git a/connector_onshape/components/listener.py b/connector_onshape/components/listener.py new file mode 100644 index 000000000..6bc1aabab --- /dev/null +++ b/connector_onshape/components/listener.py @@ -0,0 +1,43 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if + +_logger = logging.getLogger(__name__) + + +class OnshapeProductListener(Component): + """Listen for product changes and queue export to Onshape. + + When default_code or name changes on a product that is bound + to Onshape, automatically queue an export job. + + Uses ``skip_if`` with ``connector_no_export`` context flag + to prevent infinite loops during import operations. + """ + + _name = "onshape.product.listener" + _inherit = "base.event.listener" + _apply_on = ["product.product"] + + @skip_if(lambda self, record, **kwargs: self.env.context.get("connector_no_export")) + def on_record_write(self, record, fields=None): + if not fields: + return + export_fields = {"default_code", "name"} + if not export_fields.intersection(set(fields)): + return + + for binding in record.onshape_bind_ids: + if binding.backend_id.state != "active": + continue + binding.with_delay( + priority=15, + description=( + f"Export product {record.default_code or record.name} " + f"to Onshape" + ), + ).export_record() diff --git a/connector_onshape/components/mapper.py b/connector_onshape/components/mapper.py new file mode 100644 index 000000000..973f24d8b --- /dev/null +++ b/connector_onshape/components/mapper.py @@ -0,0 +1,134 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +import re + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + +# McMaster-Carr catalog number pattern (e.g., 90185A632, 91845A30) +MCMASTER_RE = re.compile(r"(\d{4,}[A-Z]\d+)") +# Version suffix pattern (e.g., .0001, .0002) +VERSION_SUFFIX_RE = re.compile(r"\.\d{4}$") + + +class OnshapeProductImportMapper(Component): + """Map Onshape part data to onshape.product.product fields. + + Includes the 4-strategy SKU matching ported from + match_cad_to_odoo.py lines 60-92. + """ + + _name = "onshape.product.import.mapper" + _inherit = "onshape.base" + _usage = "import.mapper" + _apply_on = "onshape.product.product" + + def map_record(self, part_data, document=None, element=None): + part_name = part_data.get("name", "") + part_number = "" + material = "" + state = "in_progress" + + # Extract from properties if available + for prop in part_data.get("properties", []): + name = prop.get("name", "") + value = prop.get("value", "") + if name == "Part Number": + part_number = value + elif name == "Material": + material = value + elif name == "Description": + pass # we use part_name + + # Extract from appearance/material if in part data directly + if not material: + material = part_data.get("material", {}).get("displayName", "") + + return { + "onshape_name": part_name, + "onshape_part_number": part_number, + "onshape_material": material, + "onshape_state": state, + } + + def match_product(self, part_data): + """Try to match an Onshape part to an existing Odoo product. + + 4-strategy matching ported from match_cad_to_odoo.py: + 1. Exact part name match against default_code + 2. Part number from metadata against default_code + 3. McMaster catalog number extraction + 4. Case-insensitive match + + Returns (product.product recordset, match_type) or (None, None). + """ + Product = self.env["product.product"] + part_name = part_data.get("name", "").strip() + part_number = "" + + for prop in part_data.get("properties", []): + if prop.get("name") == "Part Number": + part_number = (prop.get("value") or "").strip() + break + + # Clean name: strip CAD extensions and version suffixes + base_name = re.sub(r"\.(ipt|iam|dwg|idw)$", "", part_name, flags=re.IGNORECASE) + base_no_ver = VERSION_SUFFIX_RE.sub("", base_name) + + # Strategy 1: Exact name match + if base_no_ver: + product = Product.search([("default_code", "=", base_no_ver)], limit=1) + if product: + return product, "exact_filename" + + product = Product.search([("default_code", "=", base_name)], limit=1) + if product: + return product, "exact_filename_versioned" + + # Strategy 2: Part number from Onshape metadata + if part_number: + product = Product.search([("default_code", "=", part_number)], limit=1) + if product: + return product, "part_name" + + # Part number prefix (before underscore) + pn_prefix = part_number.split("_")[0].strip() + if pn_prefix and pn_prefix != part_number: + product = Product.search([("default_code", "=", pn_prefix)], limit=1) + if product: + return product, "part_name_prefix" + + # Strategy 3: McMaster catalog number + if base_no_ver: + mcmaster_match = MCMASTER_RE.search(base_no_ver) + if mcmaster_match: + cat_num = mcmaster_match.group(1) + product = Product.search([("default_code", "=", cat_num)], limit=1) + if product: + return product, "mcmaster_catalog" + + # Strategy 4: Case-insensitive match + if base_no_ver: + product = Product.search([("default_code", "=ilike", base_no_ver)], limit=1) + if product: + return product, "case_insensitive" + + return None, None + + +class OnshapeProductExportMapper(Component): + """Map Odoo product data to Onshape metadata fields.""" + + _name = "onshape.product.export.mapper" + _inherit = "onshape.base" + _usage = "export.mapper" + _apply_on = "onshape.product.product" + + def map_record(self, binding): + return { + "part_number": binding.odoo_id.default_code or "", + "description": binding.odoo_id.name or "", + } diff --git a/connector_onshape/controllers/__init__.py b/connector_onshape/controllers/__init__.py new file mode 100644 index 000000000..4496395f5 --- /dev/null +++ b/connector_onshape/controllers/__init__.py @@ -0,0 +1 @@ +from . import webhook diff --git a/connector_onshape/controllers/webhook.py b/connector_onshape/controllers/webhook.py new file mode 100644 index 000000000..836f7aefd --- /dev/null +++ b/connector_onshape/controllers/webhook.py @@ -0,0 +1,185 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import hashlib +import hmac +import json +import logging + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class OnshapeWebhookController(http.Controller): + """Receive Onshape webhook events. + + Route: /connector_onshape/webhook/ + + Events handled: + - onshape.model.lifecycle.metadata → re-import part metadata + - onshape.workflow.transition → update lifecycle state + - onshape.revision.created → mark as released + - onshape.model.lifecycle.createversion → log version creation + + Security: Validate HMAC signature, then re-fetch from API + (never trust webhook payload directly). + """ + + @http.route( + "/connector_onshape/webhook/", + type="json", + auth="none", + methods=["POST"], + csrf=False, + ) + def webhook(self, backend_id, **kwargs): + backend = request.env["onshape.backend"].sudo().browse(backend_id).exists() + if not backend: + _logger.warning("Webhook received for unknown backend %s", backend_id) + return {"status": "error", "message": "Unknown backend"} + + # Validate HMAC signature + raw_body = request.httprequest.get_data() + if backend.webhook_secret: + signature = request.httprequest.headers.get( + "X-Onshape-Webhook-Signature", "" + ) + if not self._validate_signature( + raw_body, backend.webhook_secret, signature + ): + _logger.warning( + "Webhook signature validation failed for backend %s", + backend_id, + ) + return {"status": "error", "message": "Invalid signature"} + + try: + payload = json.loads(raw_body) + except (json.JSONDecodeError, TypeError): + return {"status": "error", "message": "Invalid JSON"} + + event = payload.get("event", "") + _logger.info( + "Onshape webhook received: event=%s backend=%s", + event, + backend_id, + ) + + # Dispatch by event type + handler = self._get_event_handler(event) + if handler: + handler(backend, payload) + return {"status": "ok"} + + _logger.debug("Unhandled webhook event: %s", event) + return {"status": "ok", "message": "Event not handled"} + + def _validate_signature(self, raw_body, secret, signature): + expected = hmac.new( + secret.encode("utf-8"), + raw_body, + digestmod=hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(expected, signature) + + def _get_event_handler(self, event): + handlers = { + "onshape.model.lifecycle.metadata": self._handle_metadata_change, + "onshape.workflow.transition": self._handle_workflow_transition, + "onshape.revision.created": self._handle_revision_created, + "onshape.model.lifecycle.createversion": (self._handle_version_created), + } + return handlers.get(event) + + def _handle_metadata_change(self, backend, payload): + """Re-import part metadata when it changes in Onshape. + + Queues a re-import (not export) so we pull fresh data from + the Onshape API rather than overwriting what was just changed. + """ + doc_id = payload.get("documentId", "") + if not doc_id: + return + + document = ( + request.env["onshape.document"] + .sudo() + .search( + [ + ("backend_id", "=", backend.id), + ("onshape_document_id", "=", doc_id), + ], + limit=1, + ) + ) + if not document: + _logger.debug("Webhook metadata change for unknown doc %s", doc_id) + return + + # Queue re-import of products for this document + backend.with_delay( + priority=10, + description=f"Re-import products for doc {document.name}", + ).action_import_products() + + def _handle_workflow_transition(self, backend, payload): + """Update lifecycle state when workflow transitions.""" + doc_id = payload.get("documentId", "") + new_state = (payload.get("transitionName") or "").lower() + + state_map = { + "release": "released", + "obsolete": "obsolete", + "in progress": "in_progress", + "pending": "pending", + } + mapped_state = state_map.get(new_state) + if not mapped_state: + return + + bindings = ( + request.env["onshape.product.product"] + .sudo() + .search( + [ + ("backend_id", "=", backend.id), + ("onshape_document_id.onshape_document_id", "=", doc_id), + ] + ) + ) + if bindings: + bindings.write({"onshape_state": mapped_state}) + _logger.info( + "Updated %d bindings to state %s for doc %s", + len(bindings), + mapped_state, + doc_id, + ) + + def _handle_revision_created(self, backend, payload): + """Mark parts as released when a revision is created.""" + doc_id = payload.get("documentId", "") + bindings = ( + request.env["onshape.product.product"] + .sudo() + .search( + [ + ("backend_id", "=", backend.id), + ("onshape_document_id.onshape_document_id", "=", doc_id), + ] + ) + ) + if bindings: + bindings.write({"onshape_state": "released"}) + + def _handle_version_created(self, backend, payload): + """Log version creation (informational).""" + doc_id = payload.get("documentId", "") + version_name = payload.get("versionName", "") + _logger.info( + "Onshape version created: doc=%s version=%s", + doc_id, + version_name, + ) diff --git a/connector_onshape/data/ir_cron_data.xml b/connector_onshape/data/ir_cron_data.xml new file mode 100644 index 000000000..0f4acbbe4 --- /dev/null +++ b/connector_onshape/data/ir_cron_data.xml @@ -0,0 +1,40 @@ + + + + + Onshape: Import Documents + + code + model.cron_import_documents() + 6 + hours + -1 + + + + + + Onshape: Import Products + + code + model.cron_import_products() + 6 + hours + -1 + + + + + + Onshape: Import BOMs + + code + model.cron_import_boms() + 12 + hours + -1 + + + + + diff --git a/connector_onshape/data/queue_job_channel_data.xml b/connector_onshape/data/queue_job_channel_data.xml new file mode 100644 index 000000000..7105d914a --- /dev/null +++ b/connector_onshape/data/queue_job_channel_data.xml @@ -0,0 +1,9 @@ + + + + + onshape + + + + diff --git a/connector_onshape/data/queue_job_function_data.xml b/connector_onshape/data/queue_job_function_data.xml new file mode 100644 index 000000000..bbbf94375 --- /dev/null +++ b/connector_onshape/data/queue_job_function_data.xml @@ -0,0 +1,36 @@ + + + + + + action_import_documents + + + + + + + + action_import_products + + + + + + + + action_import_boms + + + + + + + + export_record + + + + + + diff --git a/connector_onshape/models/__init__.py b/connector_onshape/models/__init__.py new file mode 100644 index 000000000..f47fb5b01 --- /dev/null +++ b/connector_onshape/models/__init__.py @@ -0,0 +1,9 @@ +from . import ( + mrp_bom, + onshape_backend, + onshape_document, + onshape_mrp_bom, + onshape_product_product, + product_product, + product_template, +) diff --git a/connector_onshape/models/mrp_bom.py b/connector_onshape/models/mrp_bom.py new file mode 100644 index 000000000..a9b0ed5e1 --- /dev/null +++ b/connector_onshape/models/mrp_bom.py @@ -0,0 +1,24 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class MrpBom(models.Model): + _inherit = "mrp.bom" + + onshape_bind_ids = fields.One2many( + "onshape.mrp.bom", + "odoo_id", + string="Onshape Bindings", + ) + onshape_linked = fields.Boolean( + string="Linked to Onshape", + compute="_compute_onshape_linked", + store=True, + ) + + @api.depends("onshape_bind_ids") + def _compute_onshape_linked(self): + for rec in self: + rec.onshape_linked = bool(rec.onshape_bind_ids) diff --git a/connector_onshape/models/onshape_backend.py b/connector_onshape/models/onshape_backend.py new file mode 100644 index 000000000..7e5bc0702 --- /dev/null +++ b/connector_onshape/models/onshape_backend.py @@ -0,0 +1,193 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class OnshapeBackend(models.Model): + _name = "onshape.backend" + _description = "Onshape Backend" + _inherit = "connector.backend" + + name = fields.Char(required=True, default="Onshape") + base_url = fields.Char( + string="Base URL", + required=True, + default="https://cad.onshape.com", + ) + auth_mode = fields.Selection( + [("hmac", "HMAC (API Key)"), ("oauth2", "OAuth2 (App Store)")], + string="Authentication Mode", + required=True, + default="hmac", + ) + api_key = fields.Char(string="API Access Key", groups="base.group_system") + api_secret = fields.Char(string="API Secret Key", groups="base.group_system") + oauth2_client_id = fields.Char( + string="OAuth2 Client ID", groups="base.group_system" + ) + oauth2_client_secret = fields.Char( + string="OAuth2 Client Secret", groups="base.group_system" + ) + oauth2_token = fields.Text(string="OAuth2 Token (JSON)", groups="base.group_system") + team_id = fields.Char(string="Onshape Team / Company ID") + webhook_secret = fields.Char(groups="base.group_system") + state = fields.Selection( + [("draft", "Draft"), ("checked", "Checked"), ("active", "Active")], + default="draft", + required=True, + ) + import_products_since = fields.Datetime() + auto_create_products = fields.Boolean( + string="Auto-create Products", + help="Automatically create Odoo products for unmatched Onshape parts.", + ) + default_product_category_id = fields.Many2one( + "product.category", + string="Default Product Category", + help="Category assigned to auto-created products.", + ) + document_ids = fields.One2many("onshape.document", "backend_id", string="Documents") + document_count = fields.Integer(compute="_compute_document_count") + product_binding_ids = fields.One2many( + "onshape.product.product", "backend_id", string="Product Bindings" + ) + product_binding_count = fields.Integer(compute="_compute_product_binding_count") + bom_binding_ids = fields.One2many( + "onshape.mrp.bom", "backend_id", string="BOM Bindings" + ) + bom_binding_count = fields.Integer(compute="_compute_bom_binding_count") + + @api.depends("document_ids") + def _compute_document_count(self): + for rec in self: + rec.document_count = len(rec.document_ids) + + @api.depends("product_binding_ids") + def _compute_product_binding_count(self): + for rec in self: + rec.product_binding_count = len(rec.product_binding_ids) + + @api.depends("bom_binding_ids") + def _compute_bom_binding_count(self): + for rec in self: + rec.bom_binding_count = len(rec.bom_binding_ids) + + def _get_adapter(self): + self.ensure_one() + with self.work_on("onshape.backend") as work: + return work.component(usage="backend.adapter") + + def action_check_credentials(self): + self.ensure_one() + adapter = self._get_adapter() + ok, message = adapter.check_credentials() + if ok: + self.write({"state": "checked"}) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Credentials Verified"), + "message": message, + "type": "success", + "sticky": False, + }, + } + raise UserError(_("Credential check failed: %s") % message) + + def action_activate(self): + self.ensure_one() + if self.state != "checked": + raise UserError(_("Please check credentials first.")) + self.write({"state": "active"}) + + def action_import_documents(self): + self.ensure_one() + self._check_active() + with self.work_on("onshape.document") as work: + importer = work.component(usage="batch.importer") + importer.run(backend=self) + + def action_import_products(self): + self.ensure_one() + self._check_active() + with self.work_on("onshape.product.product") as work: + importer = work.component(usage="batch.importer") + importer.run(backend=self) + + def action_import_boms(self): + self.ensure_one() + self._check_active() + with self.work_on("onshape.mrp.bom") as work: + importer = work.component(usage="batch.importer") + importer.run(backend=self) + + def action_export_part_numbers(self): + self.ensure_one() + self._check_active() + bindings = self.product_binding_ids.filtered(lambda b: b.odoo_id.default_code) + for binding in bindings: + binding.with_delay().export_record() + + def _check_active(self): + if self.state != "active": + raise UserError( + _("Backend must be active. Please check credentials and activate.") + ) + + def action_open_documents(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Onshape Documents"), + "res_model": "onshape.document", + "view_mode": "tree,form", + "domain": [("backend_id", "=", self.id)], + "context": {"default_backend_id": self.id}, + } + + def action_open_product_bindings(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Onshape Product Bindings"), + "res_model": "onshape.product.product", + "view_mode": "tree,form", + "domain": [("backend_id", "=", self.id)], + "context": {"default_backend_id": self.id}, + } + + def action_open_bom_bindings(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Onshape BOM Bindings"), + "res_model": "onshape.mrp.bom", + "view_mode": "tree,form", + "domain": [("backend_id", "=", self.id)], + "context": {"default_backend_id": self.id}, + } + + @api.model + def cron_import_documents(self): + backends = self.search([("state", "=", "active")]) + for backend in backends: + backend.action_import_documents() + + @api.model + def cron_import_products(self): + backends = self.search([("state", "=", "active")]) + for backend in backends: + backend.action_import_products() + + @api.model + def cron_import_boms(self): + backends = self.search([("state", "=", "active")]) + for backend in backends: + backend.action_import_boms() diff --git a/connector_onshape/models/onshape_document.py b/connector_onshape/models/onshape_document.py new file mode 100644 index 000000000..b69e9b397 --- /dev/null +++ b/connector_onshape/models/onshape_document.py @@ -0,0 +1,120 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class OnshapeDocument(models.Model): + _name = "onshape.document" + _description = "Onshape Document" + _order = "name" + + backend_id = fields.Many2one( + "onshape.backend", + string="Backend", + required=True, + ondelete="cascade", + index=True, + ) + name = fields.Char(required=True, index=True) + onshape_document_id = fields.Char( + string="Document ID", + required=True, + index=True, + help="24-character hex Onshape document identifier.", + ) + onshape_default_workspace_id = fields.Char( + string="Default Workspace ID", + help="Active workspace for this document.", + ) + document_type = fields.Selection( + [ + ("assembly", "Assembly"), + ("part", "Part Studio"), + ("drawing", "Drawing"), + ("other", "Other"), + ], + default="other", + ) + thumbnail = fields.Binary(attachment=True) + element_ids = fields.One2many( + "onshape.document.element", "document_id", string="Elements" + ) + product_binding_ids = fields.One2many( + "onshape.product.product", + "onshape_document_id", + string="Product Bindings", + ) + bom_binding_ids = fields.One2many( + "onshape.mrp.bom", + "onshape_document_id", + string="BOM Bindings", + ) + onshape_url = fields.Char( + string="Onshape URL", + compute="_compute_onshape_url", + store=True, + ) + owner = fields.Char() + created_at = fields.Datetime(string="Created in Onshape") + modified_at = fields.Datetime(string="Last Modified in Onshape") + + _sql_constraints = [ + ( + "unique_document", + "unique(backend_id, onshape_document_id)", + "This Onshape document is already registered on this backend.", + ), + ] + + @api.depends("backend_id.base_url", "onshape_document_id") + def _compute_onshape_url(self): + for rec in self: + if rec.backend_id.base_url and rec.onshape_document_id: + rec.onshape_url = ( + f"{rec.backend_id.base_url}" f"/documents/{rec.onshape_document_id}" + ) + else: + rec.onshape_url = False + + @api.depends("name", "onshape_document_id") + def _compute_display_name(self): + for rec in self: + if rec.onshape_document_id: + rec.display_name = f"[{rec.onshape_document_id[:8]}] {rec.name}" + else: + rec.display_name = rec.name + + +class OnshapeDocumentElement(models.Model): + _name = "onshape.document.element" + _description = "Onshape Document Element" + + document_id = fields.Many2one( + "onshape.document", + string="Document", + required=True, + ondelete="cascade", + index=True, + ) + backend_id = fields.Many2one(related="document_id.backend_id", store=True) + name = fields.Char(required=True) + onshape_element_id = fields.Char(string="Element ID", required=True, index=True) + element_type = fields.Selection( + [ + ("partstudio", "Part Studio"), + ("assembly", "Assembly"), + ("drawing", "Drawing"), + ("blob", "Blob"), + ("application", "Application"), + ], + ) + microversion_id = fields.Char(string="Microversion ID") + + _sql_constraints = [ + ( + "unique_element", + "unique(document_id, onshape_element_id)", + "This element already exists in this document.", + ), + ] diff --git a/connector_onshape/models/onshape_mrp_bom.py b/connector_onshape/models/onshape_mrp_bom.py new file mode 100644 index 000000000..a382d643a --- /dev/null +++ b/connector_onshape/models/onshape_mrp_bom.py @@ -0,0 +1,79 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class OnshapeMrpBom(models.Model): + _name = "onshape.mrp.bom" + _description = "Onshape BOM Binding" + _inherits = {"mrp.bom": "odoo_id"} + + odoo_id = fields.Many2one( + "mrp.bom", + string="Odoo BOM", + required=True, + ondelete="cascade", + index=True, + ) + backend_id = fields.Many2one( + "onshape.backend", + string="Backend", + required=True, + ondelete="restrict", + index=True, + ) + external_id = fields.Char( + string="External ID", + index=True, + help="Compound key: document_id/element_id", + ) + onshape_document_id = fields.Many2one( + "onshape.document", + string="Onshape Document", + ondelete="set null", + index=True, + ) + onshape_element_id = fields.Char(string="Element ID") + match_score = fields.Float( + string="Component Match Score", + digits=(5, 3), + help="Percentage of components matched between Onshape and Odoo.", + ) + last_bom_hash = fields.Char( + string="BOM Hash", + help="Hash of BOM contents for change detection.", + ) + sync_date = fields.Datetime(string="Last Sync Date") + onshape_url = fields.Char( + string="Onshape URL", + compute="_compute_onshape_url", + store=True, + ) + + _sql_constraints = [ + ( + "unique_binding", + "unique(backend_id, external_id)", + "This Onshape assembly BOM is already bound on this backend.", + ), + ] + + @api.depends( + "onshape_document_id.backend_id.base_url", + "onshape_document_id.onshape_document_id", + "onshape_element_id", + ) + def _compute_onshape_url(self): + for rec in self: + doc = rec.onshape_document_id + if doc and doc.backend_id.base_url and doc.onshape_document_id: + workspace = doc.onshape_default_workspace_id or "" + elem = rec.onshape_element_id or "" + rec.onshape_url = ( + f"{doc.backend_id.base_url}" + f"/documents/{doc.onshape_document_id}" + f"/w/{workspace}/e/{elem}" + ) + else: + rec.onshape_url = False diff --git a/connector_onshape/models/onshape_product_product.py b/connector_onshape/models/onshape_product_product.py new file mode 100644 index 000000000..853db3d5b --- /dev/null +++ b/connector_onshape/models/onshape_product_product.py @@ -0,0 +1,109 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class OnshapeProductProduct(models.Model): + _name = "onshape.product.product" + _description = "Onshape Product Binding" + _inherits = {"product.product": "odoo_id"} + + odoo_id = fields.Many2one( + "product.product", + string="Odoo Product", + required=True, + ondelete="cascade", + index=True, + ) + backend_id = fields.Many2one( + "onshape.backend", + string="Backend", + required=True, + ondelete="restrict", + index=True, + ) + external_id = fields.Char( + string="External ID", + index=True, + help="Compound key: document_id/element_id/part_id", + ) + onshape_document_id = fields.Many2one( + "onshape.document", + string="Onshape Document", + ondelete="set null", + index=True, + ) + onshape_element_id = fields.Char(string="Element ID") + onshape_part_id = fields.Char(string="Part ID") + onshape_part_number = fields.Char( + help="Part number as stored in Onshape metadata.", + ) + onshape_name = fields.Char( + string="Onshape Part Name", + help="Part name as stored in Onshape.", + ) + onshape_material = fields.Char() + onshape_state = fields.Selection( + [ + ("in_progress", "In Progress"), + ("pending", "Pending"), + ("released", "Released"), + ("obsolete", "Obsolete"), + ], + string="Onshape Lifecycle State", + default="in_progress", + ) + onshape_thumbnail = fields.Binary(attachment=True) + match_type = fields.Selection( + [ + ("exact_filename", "Exact Filename"), + ("exact_filename_versioned", "Exact Filename (Versioned)"), + ("part_name", "Part Name"), + ("part_name_prefix", "Part Name Prefix"), + ("mcmaster_catalog", "McMaster Catalog"), + ("case_insensitive", "Case Insensitive"), + ("manual", "Manual"), + ("auto_created", "Auto Created"), + ], + help="How this Onshape part was matched to the Odoo product.", + ) + sync_date = fields.Datetime(string="Last Sync Date") + onshape_url = fields.Char( + string="Onshape URL", + compute="_compute_onshape_url", + store=True, + ) + + _sql_constraints = [ + ( + "unique_binding", + "unique(backend_id, external_id)", + "This Onshape part is already bound on this backend.", + ), + ] + + @api.depends( + "onshape_document_id.backend_id.base_url", + "onshape_document_id.onshape_document_id", + "onshape_element_id", + ) + def _compute_onshape_url(self): + for rec in self: + doc = rec.onshape_document_id + if doc and doc.backend_id.base_url and doc.onshape_document_id: + workspace = doc.onshape_default_workspace_id or "" + elem = rec.onshape_element_id or "" + rec.onshape_url = ( + f"{doc.backend_id.base_url}" + f"/documents/{doc.onshape_document_id}" + f"/w/{workspace}/e/{elem}" + ) + else: + rec.onshape_url = False + + def export_record(self): + self.ensure_one() + with self.backend_id.work_on("onshape.product.product") as work: + exporter = work.component(usage="record.exporter") + return exporter.run(self) diff --git a/connector_onshape/models/product_product.py b/connector_onshape/models/product_product.py new file mode 100644 index 000000000..8f74e724d --- /dev/null +++ b/connector_onshape/models/product_product.py @@ -0,0 +1,34 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + onshape_bind_ids = fields.One2many( + "onshape.product.product", + "odoo_id", + string="Onshape Bindings", + ) + onshape_linked = fields.Boolean( + string="Linked to Onshape", + compute="_compute_onshape_linked", + store=True, + ) + onshape_url = fields.Char( + string="Onshape URL", + compute="_compute_onshape_url", + ) + + @api.depends("onshape_bind_ids") + def _compute_onshape_linked(self): + for rec in self: + rec.onshape_linked = bool(rec.onshape_bind_ids) + + @api.depends("onshape_bind_ids.onshape_url") + def _compute_onshape_url(self): + for rec in self: + binding = rec.onshape_bind_ids[:1] + rec.onshape_url = binding.onshape_url if binding else False diff --git a/connector_onshape/models/product_template.py b/connector_onshape/models/product_template.py new file mode 100644 index 000000000..61f787ee3 --- /dev/null +++ b/connector_onshape/models/product_template.py @@ -0,0 +1,23 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + onshape_document_count = fields.Integer( + string="Onshape Documents", + compute="_compute_onshape_document_count", + ) + + @api.depends("product_variant_ids.onshape_bind_ids") + def _compute_onshape_document_count(self): + for rec in self: + doc_ids = set() + for variant in rec.product_variant_ids: + for bind in variant.onshape_bind_ids: + if bind.onshape_document_id: + doc_ids.add(bind.onshape_document_id.id) + rec.onshape_document_count = len(doc_ids) diff --git a/connector_onshape/readme/CONFIGURE.rst b/connector_onshape/readme/CONFIGURE.rst new file mode 100644 index 000000000..52869d48e --- /dev/null +++ b/connector_onshape/readme/CONFIGURE.rst @@ -0,0 +1,107 @@ +Onshape Developer Portal Setup +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Before configuring the Odoo backend, you need API credentials from Onshape. + +**HMAC API Keys (quickest to start):** + +1. Sign in at https://cad.onshape.com +2. Go to your **User Menu** (top-right) > **My Account** > **API keys** + (or visit https://dev-portal.onshape.com/keys directly) +3. Click **Create new API key** +4. Give it a name (e.g. ``Odoo Connector``) and select scopes: + + * ``OAuth2Read`` — read documents, parts, assemblies, metadata + * ``OAuth2Write`` — write metadata (Part Number, Description) + * ``OAuth2Delete`` — only if you need webhook management + +5. Copy the **Access key** and **Secret key** — the secret is shown only once. + +**Finding your Team / Company ID:** + +1. Go to https://cad.onshape.com +2. Click **Teams** in the left sidebar +3. Click on your team — the URL will show the team ID: + ``https://cad.onshape.com/team/`` + +**OAuth2 App Store (recommended for production):** + +HMAC keys and private OAuth2 apps count against an annual API quota +(~10,000 calls/user/year for Enterprise). Only **publicly listed App Store +apps** are exempt. To set up OAuth2: + +1. Go to https://dev-portal.onshape.com > **OAuth applications** +2. Click **Create new OAuth application** +3. Fill in: + + * **Name**: Your app name (e.g. ``Kencove Odoo Connector``) + * **Primary Format**: ``com.yourcompany.odoo-connector`` (cannot change later) + * **Redirect URLs**: ``https://your-odoo.com/connector_onshape/oauth/callback`` + * **OAuth Scopes**: ``OAuth2Read``, ``OAuth2Write`` + +4. For quota exemption, submit the app for App Store review + by emailing ``onshape-developer-relations@ptc.com`` + +Odoo Backend Configuration +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Install the ``connector_onshape`` module. +2. Go to **Onshape > Configuration > Backends** and create a new backend. +3. Fill in the connection details: + + * **Base URL**: ``https://cad.onshape.com`` (default) + * **Authentication Mode**: HMAC or OAuth2 + * **API Key / Secret**: From step above (HMAC mode) + * **Team / Company ID**: From step above + +4. Click **Check Credentials** — should show a green success notification. +5. Click **Activate** to enable the backend. + +Import Settings +~~~~~~~~~~~~~~~ + +* **Auto-create Products**: When enabled, creates new Odoo products for + Onshape parts that don't match any existing SKU. When disabled, unmatched + parts are skipped (no binding created). +* **Default Product Category**: Category assigned to auto-created products. +* **Import Products Since**: Only import parts modified after this date + (for incremental sync). + +Webhooks (Real-Time Sync) +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Webhooks push Onshape changes to Odoo in real time instead of waiting +for the next scheduled sync. + +1. In Odoo, note your backend ID (visible in the URL when viewing the backend + form, e.g. ``/web#id=1&model=onshape.backend``). + +2. Your webhook URL is: + ``https://your-odoo-instance.com/connector_onshape/webhook/`` + +3. Generate a webhook secret (any random string, e.g. ``openssl rand -hex 32``) + and enter it in the **Webhook Secret** field on the backend form. + +4. Register the webhook in Onshape. You can do this via the Onshape API + or by clicking the **Register Webhook** button (if available) on the backend. + The module listens for these events: + + * ``onshape.model.lifecycle.metadata`` — re-imports part metadata + * ``onshape.workflow.transition`` — updates lifecycle state + * ``onshape.revision.created`` — marks parts as released + * ``onshape.model.lifecycle.createversion`` — logs version creation + +5. Ensure your Odoo instance is reachable from the internet (Onshape must + be able to POST to the webhook URL). + +Scheduled Sync (Cron Jobs) +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Three cron jobs are created (disabled by default): + +* **Onshape: Import Documents** — every 6 hours +* **Onshape: Import Products** — every 6 hours +* **Onshape: Import BOMs** — every 12 hours + +Enable them in **Settings > Technical > Automation > Scheduled Actions** +when you're ready for automatic background synchronization. diff --git a/connector_onshape/readme/CONTRIBUTORS.rst b/connector_onshape/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..6991288e5 --- /dev/null +++ b/connector_onshape/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Kencove Farm Fence Supplies diff --git a/connector_onshape/readme/DESCRIPTION.rst b/connector_onshape/readme/DESCRIPTION.rst new file mode 100644 index 000000000..f38302125 --- /dev/null +++ b/connector_onshape/readme/DESCRIPTION.rst @@ -0,0 +1,20 @@ +This module provides bidirectional synchronization between Odoo and +`Onshape `_ cloud CAD/PLM platform. + +It synchronizes: + +* **Documents**: Import Onshape documents and their elements (part studios, + assemblies, drawings). +* **Products**: Bind Onshape parts to Odoo products using a 4-strategy + SKU matching algorithm (exact filename, part number, McMaster catalog, + case-insensitive). +* **Bills of Materials**: Import Onshape assembly BOMs as ``mrp.bom`` records + with component match scoring. +* **Metadata Export**: Push Odoo product SKUs and names back to Onshape + part metadata (Part Number, Description fields). +* **Webhooks**: Receive real-time notifications from Onshape for metadata + changes, workflow transitions, and revision creation. + +The module uses the OCA Connector framework with queue_job for asynchronous +processing and supports both HMAC (API Key) and OAuth2 (App Store) +authentication modes. diff --git a/connector_onshape/readme/USAGE.rst b/connector_onshape/readme/USAGE.rst new file mode 100644 index 000000000..c0b727994 --- /dev/null +++ b/connector_onshape/readme/USAGE.rst @@ -0,0 +1,47 @@ +Import Documents +~~~~~~~~~~~~~~~~ + +Click **Import Documents** on the backend form to fetch all Onshape +documents from your team. Documents are created with their elements +(part studios, assemblies, drawings). + +Import Products +~~~~~~~~~~~~~~~ + +Click **Import Products** to scan all part studio elements and create +product bindings. The module uses a 4-strategy matching algorithm: + +1. **Exact filename**: Part name matches an Odoo product SKU +2. **Part number**: Onshape Part Number metadata matches an Odoo SKU +3. **McMaster catalog**: Extracted catalog numbers (e.g., 90185A632) match +4. **Case-insensitive**: Fallback case-insensitive match + +Unmatched parts will be auto-created as products if configured. + +Import BOMs +~~~~~~~~~~~ + +Click **Import BOMs** to fetch assembly BOMs from Onshape and create +``mrp.bom`` records. Each BOM includes a **match score** indicating +what percentage of Onshape components were matched to Odoo products. + +Export Part Numbers +~~~~~~~~~~~~~~~~~~~ + +Click **Export Part Numbers** to push Odoo product SKUs and names +back to Onshape. This writes the ``default_code`` as "Part Number" +and ``name`` as "Description" in Onshape metadata. + +Automatic Export +~~~~~~~~~~~~~~~~ + +When a product's ``default_code`` or ``name`` is changed in Odoo, +a background job is automatically queued to export the update to +Onshape (if the product is bound to an Onshape part). + +Import Wizard +~~~~~~~~~~~~~ + +Use **Onshape > Onshape Data > Import from Onshape** for a guided +import process with options for documents-only, documents+products, +or full sync including BOMs. diff --git a/connector_onshape/security/ir.model.access.csv b/connector_onshape/security/ir.model.access.csv new file mode 100644 index 000000000..61bc57fee --- /dev/null +++ b/connector_onshape/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_onshape_backend_user,onshape.backend user,model_onshape_backend,group_onshape_user,1,0,0,0 +access_onshape_backend_manager,onshape.backend manager,model_onshape_backend,group_onshape_manager,1,1,1,1 +access_onshape_document_user,onshape.document user,model_onshape_document,group_onshape_user,1,0,0,0 +access_onshape_document_manager,onshape.document manager,model_onshape_document,group_onshape_manager,1,1,1,1 +access_onshape_document_element_user,onshape.document.element user,model_onshape_document_element,group_onshape_user,1,0,0,0 +access_onshape_document_element_manager,onshape.document.element manager,model_onshape_document_element,group_onshape_manager,1,1,1,1 +access_onshape_product_product_user,onshape.product.product user,model_onshape_product_product,group_onshape_user,1,0,0,0 +access_onshape_product_product_manager,onshape.product.product manager,model_onshape_product_product,group_onshape_manager,1,1,1,1 +access_onshape_mrp_bom_user,onshape.mrp.bom user,model_onshape_mrp_bom,group_onshape_user,1,0,0,0 +access_onshape_mrp_bom_manager,onshape.mrp.bom manager,model_onshape_mrp_bom,group_onshape_manager,1,1,1,1 +access_onshape_import_wizard_manager,onshape.import.wizard manager,model_onshape_import_wizard,group_onshape_manager,1,1,1,1 diff --git a/connector_onshape/security/onshape_security.xml b/connector_onshape/security/onshape_security.xml new file mode 100644 index 000000000..ba21ac48d --- /dev/null +++ b/connector_onshape/security/onshape_security.xml @@ -0,0 +1,21 @@ + + + + + Onshape + 100 + + + + User + + + + + + Manager + + + + + diff --git a/connector_onshape/static/description/icon.png b/connector_onshape/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..33cbd38af4846ff76400320d683200f761f81c2d GIT binary patch literal 299 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSpFCY0Ln`LHy|hq}!GVLt@Yj15 z@8#Q;31duk$kO4c6YE--~@;{8|*eN?l^6= SYv(rxAn + + OS + diff --git a/connector_onshape/static/description/index.html b/connector_onshape/static/description/index.html new file mode 100644 index 000000000..c5facbd38 --- /dev/null +++ b/connector_onshape/static/description/index.html @@ -0,0 +1,610 @@ + + + + + +Onshape Connector + + + +
+

Onshape Connector

+ + +

Beta License: AGPL-3 OCA/connector Translate me on Weblate Try me on Runboat

+

This module provides bidirectional synchronization between Odoo and +Onshape cloud CAD/PLM platform.

+

It synchronizes:

+
    +
  • Documents: Import Onshape documents and their elements (part studios, +assemblies, drawings).
  • +
  • Products: Bind Onshape parts to Odoo products using a 4-strategy +SKU matching algorithm (exact filename, part number, McMaster catalog, +case-insensitive).
  • +
  • Bills of Materials: Import Onshape assembly BOMs as mrp.bom records +with component match scoring.
  • +
  • Metadata Export: Push Odoo product SKUs and names back to Onshape +part metadata (Part Number, Description fields).
  • +
  • Webhooks: Receive real-time notifications from Onshape for metadata +changes, workflow transitions, and revision creation.
  • +
+

The module uses the OCA Connector framework with queue_job for asynchronous +processing and supports both HMAC (API Key) and OAuth2 (App Store) +authentication modes.

+

Table of contents

+ +
+

Configuration

+
+

Onshape Developer Portal Setup

+

Before configuring the Odoo backend, you need API credentials from Onshape.

+

HMAC API Keys (quickest to start):

+
    +
  1. Sign in at https://cad.onshape.com
  2. +
  3. Go to your User Menu (top-right) > My Account > API keys +(or visit https://dev-portal.onshape.com/keys directly)
  4. +
  5. Click Create new API key
  6. +
  7. Give it a name (e.g. Odoo Connector) and select scopes:
      +
    • OAuth2Read — read documents, parts, assemblies, metadata
    • +
    • OAuth2Write — write metadata (Part Number, Description)
    • +
    • OAuth2Delete — only if you need webhook management
    • +
    +
  8. +
  9. Copy the Access key and Secret key — the secret is shown only once.
  10. +
+

Finding your Team / Company ID:

+
    +
  1. Go to https://cad.onshape.com
  2. +
  3. Click Teams in the left sidebar
  4. +
  5. Click on your team — the URL will show the team ID: +https://cad.onshape.com/team/<TEAM_ID>
  6. +
+

OAuth2 App Store (recommended for production):

+

HMAC keys and private OAuth2 apps count against an annual API quota +(~10,000 calls/user/year for Enterprise). Only publicly listed App Store +apps are exempt. To set up OAuth2:

+
    +
  1. Go to https://dev-portal.onshape.com > OAuth applications
  2. +
  3. Click Create new OAuth application
  4. +
  5. Fill in:
      +
    • Name: Your app name (e.g. Kencove Odoo Connector)
    • +
    • Primary Format: com.yourcompany.odoo-connector (cannot change later)
    • +
    • Redirect URLs: https://your-odoo.com/connector_onshape/oauth/callback
    • +
    • OAuth Scopes: OAuth2Read, OAuth2Write
    • +
    +
  6. +
  7. For quota exemption, submit the app for App Store review +by emailing onshape-developer-relations@ptc.com
  8. +
+
+
+

Odoo Backend Configuration

+
    +
  1. Install the connector_onshape module.
  2. +
  3. Go to Onshape > Configuration > Backends and create a new backend.
  4. +
  5. Fill in the connection details:
      +
    • Base URL: https://cad.onshape.com (default)
    • +
    • Authentication Mode: HMAC or OAuth2
    • +
    • API Key / Secret: From step above (HMAC mode)
    • +
    • Team / Company ID: From step above
    • +
    +
  6. +
  7. Click Check Credentials — should show a green success notification.
  8. +
  9. Click Activate to enable the backend.
  10. +
+
+
+

Import Settings

+
    +
  • Auto-create Products: When enabled, creates new Odoo products for +Onshape parts that don’t match any existing SKU. When disabled, unmatched +parts are skipped (no binding created).
  • +
  • Default Product Category: Category assigned to auto-created products.
  • +
  • Import Products Since: Only import parts modified after this date +(for incremental sync).
  • +
+
+
+

Webhooks (Real-Time Sync)

+

Webhooks push Onshape changes to Odoo in real time instead of waiting +for the next scheduled sync.

+
    +
  1. In Odoo, note your backend ID (visible in the URL when viewing the backend +form, e.g. /web#id=1&model=onshape.backend).
  2. +
  3. Your webhook URL is: +https://your-odoo-instance.com/connector_onshape/webhook/<backend_id>
  4. +
  5. Generate a webhook secret (any random string, e.g. openssl rand -hex 32) +and enter it in the Webhook Secret field on the backend form.
  6. +
  7. Register the webhook in Onshape. You can do this via the Onshape API +or by clicking the Register Webhook button (if available) on the backend. +The module listens for these events:
      +
    • onshape.model.lifecycle.metadata — re-imports part metadata
    • +
    • onshape.workflow.transition — updates lifecycle state
    • +
    • onshape.revision.created — marks parts as released
    • +
    • onshape.model.lifecycle.createversion — logs version creation
    • +
    +
  8. +
  9. Ensure your Odoo instance is reachable from the internet (Onshape must +be able to POST to the webhook URL).
  10. +
+
+
+

Scheduled Sync (Cron Jobs)

+

Three cron jobs are created (disabled by default):

+
    +
  • Onshape: Import Documents — every 6 hours
  • +
  • Onshape: Import Products — every 6 hours
  • +
  • Onshape: Import BOMs — every 12 hours
  • +
+

Enable them in Settings > Technical > Automation > Scheduled Actions +when you’re ready for automatic background synchronization.

+
+
+
+

Usage

+
+

Import Documents

+

Click Import Documents on the backend form to fetch all Onshape +documents from your team. Documents are created with their elements +(part studios, assemblies, drawings).

+
+
+

Import Products

+

Click Import Products to scan all part studio elements and create +product bindings. The module uses a 4-strategy matching algorithm:

+
    +
  1. Exact filename: Part name matches an Odoo product SKU
  2. +
  3. Part number: Onshape Part Number metadata matches an Odoo SKU
  4. +
  5. McMaster catalog: Extracted catalog numbers (e.g., 90185A632) match
  6. +
  7. Case-insensitive: Fallback case-insensitive match
  8. +
+

Unmatched parts will be auto-created as products if configured.

+
+
+

Import BOMs

+

Click Import BOMs to fetch assembly BOMs from Onshape and create +mrp.bom records. Each BOM includes a match score indicating +what percentage of Onshape components were matched to Odoo products.

+
+
+

Export Part Numbers

+

Click Export Part Numbers to push Odoo product SKUs and names +back to Onshape. This writes the default_code as “Part Number” +and name as “Description” in Onshape metadata.

+
+
+

Automatic Export

+

When a product’s default_code or name is changed in Odoo, +a background job is automatically queued to export the update to +Onshape (if the product is bound to an Onshape part).

+
+
+

Import Wizard

+

Use Onshape > Onshape Data > Import from Onshape for a guided +import process with options for documents-only, documents+products, +or full sync including BOMs.

+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Kencove Farm Fence Supplies
  • +
+
+
+

Contributors

+
    +
  • Kencove Farm Fence Supplies
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/connector project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/connector_onshape/tests/__init__.py b/connector_onshape/tests/__init__.py new file mode 100644 index 000000000..cd36c0368 --- /dev/null +++ b/connector_onshape/tests/__init__.py @@ -0,0 +1,11 @@ +from . import ( + test_adapter, + test_backend, + test_export_product, + test_import_bom, + test_import_product, + test_importer_flow, + test_listener, + test_webhook, + test_wizard, +) diff --git a/connector_onshape/tests/common.py b/connector_onshape/tests/common.py new file mode 100644 index 000000000..e35d0f344 --- /dev/null +++ b/connector_onshape/tests/common.py @@ -0,0 +1,168 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import MagicMock + +from odoo.tests.common import TransactionCase + +MOCK_DOCUMENT = { + "id": "abc123def456abc123def456", + "name": "Test Assembly", + "defaultWorkspace": {"id": "ws_001"}, + "createdAt": "2024-01-01T00:00:00Z", + "modifiedAt": "2024-06-01T12:00:00Z", + "owner": {"name": "Test User"}, +} + +MOCK_DOCUMENTS_RESPONSE = { + "items": [MOCK_DOCUMENT], + "next": None, +} + +MOCK_ELEMENTS = [ + { + "id": "elem_ps_001", + "name": "Part Studio 1", + "elementType": "PARTSTUDIO", + "microversionId": "mv_001", + }, + { + "id": "elem_asm_001", + "name": "Assembly 1", + "elementType": "ASSEMBLY", + "microversionId": "mv_002", + }, +] + +MOCK_PARTS = [ + { + "partId": "part_001", + "name": "KF-BOLT-001", + "properties": [ + {"name": "Part Number", "propertyId": "pn_001", "value": ""}, + {"name": "Description", "propertyId": "desc_001", "value": ""}, + {"name": "Material", "propertyId": "mat_001", "value": "Steel"}, + ], + }, + { + "partId": "part_002", + "name": "90185A632", + "properties": [ + {"name": "Part Number", "propertyId": "pn_002", "value": "90185A632"}, + {"name": "Description", "propertyId": "desc_002", "value": "McMaster bolt"}, + ], + }, +] + +MOCK_PART_METADATA = { + "items": [ + { + "partId": "part_001", + "href": "https://cad.onshape.com/api/metadata/...", + "properties": [ + {"name": "Part Number", "propertyId": "pn_001", "value": ""}, + {"name": "Description", "propertyId": "desc_001", "value": ""}, + ], + } + ] +} + +MOCK_ASSEMBLY_BOM = { + "bomTable": { + "items": [ + { + "name": "KF-BOLT-001", + "partNumber": "KF-BOLT-001", + "quantity": 4, + }, + { + "name": "KF-NUT-001", + "partNumber": "KF-NUT-001", + "quantity": 4, + }, + { + "name": "Unknown Part", + "partNumber": "", + "quantity": 1, + }, + ] + } +} + + +class OnshapeTestCase(TransactionCase): + """Base test case with Onshape backend and mock fixtures.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.backend = cls.env["onshape.backend"].create( + { + "name": "Test Onshape", + "base_url": "https://cad.onshape.com", + "auth_mode": "hmac", + "api_key": "test_api_key", + "api_secret": "test_api_secret", + "team_id": "team_test_001", + "state": "active", + } + ) + cls.product_bolt = cls.env["product.product"].create( + { + "name": "Hex Bolt 3/8-16", + "default_code": "KF-BOLT-001", + "type": "product", + } + ) + cls.product_nut = cls.env["product.product"].create( + { + "name": "Hex Nut 3/8-16", + "default_code": "KF-NUT-001", + "type": "product", + } + ) + cls.product_mcmaster = cls.env["product.product"].create( + { + "name": "McMaster Grade 5 Bolt", + "default_code": "90185A632", + "type": "product", + } + ) + cls.category = cls.env["product.category"].create( + {"name": "Onshape Test Category"} + ) + + def _create_mock_document(self): + return self.env["onshape.document"].create( + { + "backend_id": self.backend.id, + "name": "Test Assembly", + "onshape_document_id": "abc123def456abc123def456", + "onshape_default_workspace_id": "ws_001", + "document_type": "assembly", + } + ) + + def _create_mock_element(self, document, elem_type="partstudio"): + return self.env["onshape.document.element"].create( + { + "document_id": document.id, + "name": f"Test {elem_type}", + "onshape_element_id": f"elem_{elem_type}_001", + "element_type": elem_type, + } + ) + + def _mock_adapter(self): + """Return a mock adapter with pre-configured responses.""" + adapter = MagicMock() + adapter.check_credentials.return_value = (True, "OK") + adapter.search_documents.return_value = MOCK_DOCUMENTS_RESPONSE + adapter.read_document.return_value = MOCK_DOCUMENT + adapter.read_document_elements.return_value = MOCK_ELEMENTS + adapter.read_parts.return_value = MOCK_PARTS + adapter.read_part_metadata.return_value = MOCK_PART_METADATA + adapter.read_assembly_bom.return_value = MOCK_ASSEMBLY_BOM + adapter.write_part_metadata.return_value = {} + adapter.read_thumbnail.return_value = None + return adapter diff --git a/connector_onshape/tests/test_adapter.py b/connector_onshape/tests/test_adapter.py new file mode 100644 index 000000000..eb1c302f9 --- /dev/null +++ b/connector_onshape/tests/test_adapter.py @@ -0,0 +1,89 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +from unittest.mock import MagicMock, patch + +from .common import OnshapeTestCase + + +class TestOnshapeAdapter(OnshapeTestCase): + """Test the HMAC signing logic and adapter methods.""" + + def _get_adapter_component(self): + with self.backend.work_on("onshape.backend") as work: + return work.component(usage="backend.adapter") + + def test_hmac_header_generation(self): + adapter = self._get_adapter_component() + headers = adapter._hmac_headers("GET", "/api/v6/documents") + + self.assertIn("Authorization", headers) + self.assertIn("Date", headers) + self.assertIn("On-Nonce", headers) + self.assertTrue( + headers["Authorization"].startswith("On test_api_key:HmacSHA256:") + ) + + def test_hmac_signature_correctness(self): + adapter = self._get_adapter_component() + headers = adapter._hmac_headers( + "GET", + "/api/v6/documents", + query_params={"limit": "1"}, + content_type="", + ) + + # Verify the signature structure + auth = headers["Authorization"] + parts = auth.split(":") + self.assertEqual(len(parts), 3) + self.assertEqual(parts[0], "On test_api_key") + self.assertEqual(parts[1], "HmacSHA256") + # Verify base64 decoding works + sig_bytes = base64.b64decode(parts[2]) + self.assertEqual(len(sig_bytes), 32) # SHA256 = 32 bytes + + def test_canonical_query(self): + adapter = self._get_adapter_component() + self.assertEqual(adapter._canonical_query(None), "") + self.assertEqual(adapter._canonical_query({}), "") + result = adapter._canonical_query({"b": "2", "a": "1"}) + self.assertEqual(result, "a=1&b=2") + + @patch("odoo.addons.connector_onshape.components.adapter.requests") + def test_check_credentials_success(self, mock_requests): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"items": []} + mock_requests.request.return_value = mock_resp + + adapter = self._get_adapter_component() + ok, msg = adapter.check_credentials() + self.assertTrue(ok) + self.assertIn("verified", msg.lower()) + + @patch("odoo.addons.connector_onshape.components.adapter.requests") + def test_check_credentials_unauthorized(self, mock_requests): + mock_resp = MagicMock() + mock_resp.status_code = 401 + mock_resp.text = "Unauthorized" + mock_requests.request.return_value = mock_resp + + adapter = self._get_adapter_component() + ok, msg = adapter.check_credentials() + self.assertFalse(ok) + self.assertIn("401", msg) + + @patch("odoo.addons.connector_onshape.components.adapter.requests") + def test_quota_exhausted_raises(self, mock_requests): + from ..components.adapter import OnshapeQuotaError + + mock_resp = MagicMock() + mock_resp.status_code = 402 + mock_resp.headers = {} + mock_requests.request.return_value = mock_resp + + adapter = self._get_adapter_component() + with self.assertRaises(OnshapeQuotaError): + adapter._request("GET", "/api/v6/documents") diff --git a/connector_onshape/tests/test_backend.py b/connector_onshape/tests/test_backend.py new file mode 100644 index 000000000..7a3bbbec1 --- /dev/null +++ b/connector_onshape/tests/test_backend.py @@ -0,0 +1,99 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.exceptions import UserError + +from .common import OnshapeTestCase + + +class TestOnshapeBackend(OnshapeTestCase): + def test_backend_creation(self): + self.assertEqual(self.backend.state, "active") + self.assertEqual(self.backend.auth_mode, "hmac") + self.assertEqual(self.backend.base_url, "https://cad.onshape.com") + + def test_check_credentials_success(self): + backend = self.env["onshape.backend"].create( + { + "name": "Draft Backend", + "base_url": "https://cad.onshape.com", + "auth_mode": "hmac", + "api_key": "key", + "api_secret": "secret", + "state": "draft", + } + ) + with patch.object( + type(self.env["onshape.backend"]), + "_get_adapter", + ) as mock_get: + adapter = self._mock_adapter() + mock_get.return_value = adapter + result = backend.action_check_credentials() + self.assertEqual(backend.state, "checked") + self.assertEqual(result["params"]["type"], "success") + + def test_check_credentials_failure(self): + backend = self.env["onshape.backend"].create( + { + "name": "Bad Backend", + "base_url": "https://cad.onshape.com", + "auth_mode": "hmac", + "api_key": "bad_key", + "api_secret": "bad_secret", + "state": "draft", + } + ) + with patch.object( + type(self.env["onshape.backend"]), + "_get_adapter", + ) as mock_get: + adapter = self._mock_adapter() + adapter.check_credentials.return_value = (False, "Unauthorized") + mock_get.return_value = adapter + with self.assertRaises(UserError): + backend.action_check_credentials() + + def test_activate_requires_checked(self): + backend = self.env["onshape.backend"].create( + { + "name": "Draft", + "base_url": "https://cad.onshape.com", + "auth_mode": "hmac", + "api_key": "k", + "api_secret": "s", + "state": "draft", + } + ) + with self.assertRaises(UserError): + backend.action_activate() + + def test_import_requires_active(self): + backend = self.env["onshape.backend"].create( + { + "name": "Draft", + "base_url": "https://cad.onshape.com", + "auth_mode": "hmac", + "api_key": "k", + "api_secret": "s", + "state": "draft", + } + ) + with self.assertRaises(UserError): + backend.action_import_documents() + + def test_document_count(self): + self.assertEqual(self.backend.document_count, 0) + self._create_mock_document() + self.backend.invalidate_recordset() + self.assertEqual(self.backend.document_count, 1) + + def test_stat_button_actions(self): + action = self.backend.action_open_documents() + self.assertEqual(action["res_model"], "onshape.document") + action = self.backend.action_open_product_bindings() + self.assertEqual(action["res_model"], "onshape.product.product") + action = self.backend.action_open_bom_bindings() + self.assertEqual(action["res_model"], "onshape.mrp.bom") diff --git a/connector_onshape/tests/test_export_product.py b/connector_onshape/tests/test_export_product.py new file mode 100644 index 000000000..9f4d99010 --- /dev/null +++ b/connector_onshape/tests/test_export_product.py @@ -0,0 +1,78 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from .common import OnshapeTestCase + + +class TestProductExportMapper(OnshapeTestCase): + """Test the export mapper.""" + + def test_export_mapper_values(self): + doc = self._create_mock_document() + binding = self.env["onshape.product.product"].create( + { + "odoo_id": self.product_bolt.id, + "backend_id": self.backend.id, + "external_id": "abc123/elem_001/part_001", + "onshape_document_id": doc.id, + "onshape_element_id": "elem_001", + "onshape_part_id": "part_001", + } + ) + with self.backend.work_on("onshape.product.product") as work: + mapper = work.component(usage="export.mapper") + vals = mapper.map_record(binding) + + self.assertEqual(vals["part_number"], "KF-BOLT-001") + self.assertEqual(vals["description"], "Hex Bolt 3/8-16") + + def test_export_mapper_empty_sku(self): + product_no_sku = self.env["product.product"].create( + { + "name": "No SKU Product", + "type": "product", + } + ) + doc = self._create_mock_document() + binding = self.env["onshape.product.product"].create( + { + "odoo_id": product_no_sku.id, + "backend_id": self.backend.id, + "external_id": "abc123/elem_001/part_099", + "onshape_document_id": doc.id, + } + ) + with self.backend.work_on("onshape.product.product") as work: + mapper = work.component(usage="export.mapper") + vals = mapper.map_record(binding) + + self.assertEqual(vals["part_number"], "") + self.assertEqual(vals["description"], "No SKU Product") + + +class TestBinder(OnshapeTestCase): + """Test the compound ID binder.""" + + def test_make_compound_id_with_part(self): + from ..components.binder import OnshapeBinder + + result = OnshapeBinder.make_compound_id("doc1", "elem1", "part1") + self.assertEqual(result, "doc1/elem1/part1") + + def test_make_compound_id_without_part(self): + from ..components.binder import OnshapeBinder + + result = OnshapeBinder.make_compound_id("doc1", "elem1") + self.assertEqual(result, "doc1/elem1") + + def test_split_compound_id(self): + from ..components.binder import OnshapeBinder + + result = OnshapeBinder.split_compound_id("doc1/elem1/part1") + self.assertEqual( + result, + {"document_id": "doc1", "element_id": "elem1", "part_id": "part1"}, + ) + + result = OnshapeBinder.split_compound_id("doc1/elem1") + self.assertEqual(result, {"document_id": "doc1", "element_id": "elem1"}) diff --git a/connector_onshape/tests/test_import_bom.py b/connector_onshape/tests/test_import_bom.py new file mode 100644 index 000000000..99b9f0f77 --- /dev/null +++ b/connector_onshape/tests/test_import_bom.py @@ -0,0 +1,63 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from .common import OnshapeTestCase + + +class TestBomBinding(OnshapeTestCase): + """Test BOM binding creation and match scoring.""" + + def test_create_bom_binding(self): + doc = self._create_mock_document() + bom = self.env["mrp.bom"].create( + { + "product_tmpl_id": self.product_bolt.product_tmpl_id.id, + "product_id": self.product_bolt.id, + "type": "normal", + "bom_line_ids": [ + ( + 0, + 0, + { + "product_id": self.product_nut.id, + "product_qty": 4, + }, + ) + ], + } + ) + binding = self.env["onshape.mrp.bom"].create( + { + "odoo_id": bom.id, + "backend_id": self.backend.id, + "external_id": "abc123/elem_asm_001", + "onshape_document_id": doc.id, + "onshape_element_id": "elem_asm_001", + "match_score": 0.667, + "last_bom_hash": "abc123hash", + } + ) + self.assertTrue(binding.exists()) + self.assertTrue(bom.onshape_linked) + self.assertEqual(binding.match_score, 0.667) + + def test_bom_hash_change_detection(self): + doc = self._create_mock_document() + bom = self.env["mrp.bom"].create( + { + "product_tmpl_id": self.product_bolt.product_tmpl_id.id, + "type": "normal", + } + ) + binding = self.env["onshape.mrp.bom"].create( + { + "odoo_id": bom.id, + "backend_id": self.backend.id, + "external_id": "abc123/elem_asm_001", + "onshape_document_id": doc.id, + "last_bom_hash": "old_hash", + } + ) + self.assertEqual(binding.last_bom_hash, "old_hash") + binding.write({"last_bom_hash": "new_hash"}) + self.assertEqual(binding.last_bom_hash, "new_hash") diff --git a/connector_onshape/tests/test_import_product.py b/connector_onshape/tests/test_import_product.py new file mode 100644 index 000000000..4d47f8a5d --- /dev/null +++ b/connector_onshape/tests/test_import_product.py @@ -0,0 +1,125 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from psycopg2 import IntegrityError + +from .common import MOCK_PARTS, OnshapeTestCase + + +class TestProductImportMapper(OnshapeTestCase): + """Test the 4-strategy SKU matching logic.""" + + def _get_mapper(self): + with self.backend.work_on("onshape.product.product") as work: + return work.component(usage="import.mapper") + + def test_exact_filename_match(self): + mapper = self._get_mapper() + part_data = {"name": "KF-BOLT-001", "properties": []} + product, match_type = mapper.match_product(part_data) + self.assertEqual(product, self.product_bolt) + self.assertEqual(match_type, "exact_filename") + + def test_exact_filename_with_extension(self): + mapper = self._get_mapper() + part_data = {"name": "KF-BOLT-001.ipt", "properties": []} + product, match_type = mapper.match_product(part_data) + self.assertEqual(product, self.product_bolt) + self.assertEqual(match_type, "exact_filename") + + def test_exact_filename_with_version(self): + mapper = self._get_mapper() + part_data = {"name": "KF-BOLT-001.0001", "properties": []} + product, match_type = mapper.match_product(part_data) + self.assertEqual(product, self.product_bolt) + self.assertEqual(match_type, "exact_filename") + + def test_part_number_match(self): + mapper = self._get_mapper() + part_data = { + "name": "Some random name", + "properties": [ + {"name": "Part Number", "value": "KF-NUT-001"}, + ], + } + product, match_type = mapper.match_product(part_data) + self.assertEqual(product, self.product_nut) + self.assertEqual(match_type, "part_name") + + def test_mcmaster_catalog_match(self): + mapper = self._get_mapper() + part_data = { + "name": "90185A632_Grade 5 Steel Bolt", + "properties": [], + } + product, match_type = mapper.match_product(part_data) + # Should match via exact filename first (90185A632 is the default_code) + self.assertEqual(product, self.product_mcmaster) + + def test_case_insensitive_match(self): + mapper = self._get_mapper() + part_data = {"name": "kf-bolt-001", "properties": []} + product, match_type = mapper.match_product(part_data) + self.assertEqual(product, self.product_bolt) + self.assertEqual(match_type, "case_insensitive") + + def test_no_match(self): + mapper = self._get_mapper() + part_data = {"name": "TOTALLY_UNKNOWN_PART", "properties": []} + product, match_type = mapper.match_product(part_data) + self.assertIsNone(product) + self.assertIsNone(match_type) + + def test_map_record_extracts_fields(self): + mapper = self._get_mapper() + vals = mapper.map_record(MOCK_PARTS[0]) + self.assertEqual(vals["onshape_name"], "KF-BOLT-001") + self.assertEqual(vals["onshape_material"], "Steel") + + def test_map_record_with_part_number(self): + mapper = self._get_mapper() + vals = mapper.map_record(MOCK_PARTS[1]) + self.assertEqual(vals["onshape_part_number"], "90185A632") + + +class TestProductBinding(OnshapeTestCase): + """Test product binding creation.""" + + def test_create_binding(self): + doc = self._create_mock_document() + self._create_mock_element(doc) + binding = self.env["onshape.product.product"].create( + { + "odoo_id": self.product_bolt.id, + "backend_id": self.backend.id, + "external_id": "abc123/elem_ps_001/part_001", + "onshape_document_id": doc.id, + "onshape_element_id": "elem_ps_001", + "onshape_part_id": "part_001", + "onshape_name": "KF-BOLT-001", + "match_type": "exact_filename", + } + ) + self.assertTrue(binding.exists()) + self.assertTrue(self.product_bolt.onshape_linked) + self.assertIn("cad.onshape.com", binding.onshape_url or "") + + def test_compound_id_unique_constraint(self): + doc = self._create_mock_document() + self.env["onshape.product.product"].create( + { + "odoo_id": self.product_bolt.id, + "backend_id": self.backend.id, + "external_id": "abc123/elem_001/part_001", + "onshape_document_id": doc.id, + } + ) + with self.assertRaises(IntegrityError), self.cr.savepoint(): + self.env["onshape.product.product"].create( + { + "odoo_id": self.product_nut.id, + "backend_id": self.backend.id, + "external_id": "abc123/elem_001/part_001", + "onshape_document_id": doc.id, + } + ) diff --git a/connector_onshape/tests/test_importer_flow.py b/connector_onshape/tests/test_importer_flow.py new file mode 100644 index 000000000..f16dd90cb --- /dev/null +++ b/connector_onshape/tests/test_importer_flow.py @@ -0,0 +1,139 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from psycopg2 import IntegrityError + +from .common import OnshapeTestCase + + +class TestDocumentImport(OnshapeTestCase): + """Test document and element creation.""" + + def test_document_creation(self): + doc = self._create_mock_document() + self.assertTrue(doc.exists()) + self.assertEqual(doc.onshape_document_id, "abc123def456abc123def456") + self.assertEqual(doc.document_type, "assembly") + self.assertIn("cad.onshape.com", doc.onshape_url) + + def test_element_creation(self): + doc = self._create_mock_document() + elem = self._create_mock_element(doc, "partstudio") + self.assertTrue(elem.exists()) + self.assertEqual(elem.element_type, "partstudio") + self.assertEqual(elem.document_id, doc) + + def test_document_display_name(self): + doc = self._create_mock_document() + self.assertIn("[abc123de]", doc.display_name) + self.assertIn("Test Assembly", doc.display_name) + + def test_document_unique_constraint(self): + self._create_mock_document() + with self.assertRaises(IntegrityError), self.cr.savepoint(): + self._create_mock_document() + + +class TestProductRecordImporter(OnshapeTestCase): + """Test the product record importer with match logic.""" + + def test_no_auto_create_skips_unmatched(self): + """When auto_create is False and no match, no binding is created.""" + self.backend.write({"auto_create_products": False}) + doc = self._create_mock_document() + elem = self._create_mock_element(doc) + + with self.backend.work_on("onshape.product.product") as work: + importer = work.component(usage="record.importer") + result = importer.run( + backend=self.backend, + document=doc, + element=elem, + part_data={ + "partId": "unknown_part", + "name": "TOTALLY_UNKNOWN_XYZ", + "properties": [], + }, + ) + self.assertIsNone(result) + + def test_auto_create_creates_product(self): + """When auto_create is True and no match, a product is created.""" + self.backend.write( + { + "auto_create_products": True, + "default_product_category_id": self.category.id, + } + ) + doc = self._create_mock_document() + elem = self._create_mock_element(doc) + + with self.backend.work_on("onshape.product.product") as work: + importer = work.component(usage="record.importer") + binding = importer.run( + backend=self.backend, + document=doc, + element=elem, + part_data={ + "partId": "new_part_001", + "name": "Brand New Part", + "properties": [], + }, + ) + self.assertTrue(binding) + self.assertEqual(binding.match_type, "auto_created") + self.assertEqual(binding.odoo_id.categ_id, self.category) + + def test_exact_match_binding(self): + """Part named same as existing SKU creates binding with exact match.""" + doc = self._create_mock_document() + elem = self._create_mock_element(doc) + + with self.backend.work_on("onshape.product.product") as work: + importer = work.component(usage="record.importer") + binding = importer.run( + backend=self.backend, + document=doc, + element=elem, + part_data={ + "partId": "part_bolt", + "name": "KF-BOLT-001", + "properties": [], + }, + ) + self.assertTrue(binding) + self.assertEqual(binding.odoo_id, self.product_bolt) + self.assertEqual(binding.match_type, "exact_filename") + + def test_update_existing_binding(self): + """Re-importing same part updates existing binding.""" + doc = self._create_mock_document() + elem = self._create_mock_element(doc) + + with self.backend.work_on("onshape.product.product") as work: + importer = work.component(usage="record.importer") + binding1 = importer.run( + backend=self.backend, + document=doc, + element=elem, + part_data={ + "partId": "part_bolt", + "name": "KF-BOLT-001", + "properties": [], + }, + ) + # Import again - should update, not create new + binding2 = importer.run( + backend=self.backend, + document=doc, + element=elem, + part_data={ + "partId": "part_bolt", + "name": "KF-BOLT-001", + "properties": [ + {"name": "Material", "value": "Stainless"}, + ], + }, + ) + self.assertEqual(binding1, binding2) + self.assertEqual(binding2.onshape_material, "Stainless") diff --git a/connector_onshape/tests/test_listener.py b/connector_onshape/tests/test_listener.py new file mode 100644 index 000000000..f8a8910b8 --- /dev/null +++ b/connector_onshape/tests/test_listener.py @@ -0,0 +1,47 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from .common import OnshapeTestCase + + +class TestProductListener(OnshapeTestCase): + """Test the auto-export listener on product.product writes.""" + + def test_connector_no_export_context_skips(self): + """Writes with connector_no_export context should not trigger export.""" + doc = self._create_mock_document() + self.env["onshape.product.product"].create( + { + "odoo_id": self.product_bolt.id, + "backend_id": self.backend.id, + "external_id": "abc123/elem_001/part_001", + "onshape_document_id": doc.id, + } + ) + # Write with connector_no_export - should not queue any job + self.product_bolt.with_context(connector_no_export=True).write( + {"default_code": "KF-BOLT-002"} + ) + # If we got here without error, the skip_if worked + + def test_unlinked_product_no_error(self): + """Writing to a product without Onshape bindings should not error.""" + product = self.env["product.product"].create( + {"name": "No Binding", "type": "product"} + ) + # Should not raise + product.write({"default_code": "NEW-SKU"}) + + def test_irrelevant_field_no_export(self): + """Changing a field not in export_fields should not trigger export.""" + doc = self._create_mock_document() + self.env["onshape.product.product"].create( + { + "odoo_id": self.product_bolt.id, + "backend_id": self.backend.id, + "external_id": "abc123/elem_001/part_001", + "onshape_document_id": doc.id, + } + ) + # Changing 'list_price' should not trigger anything + self.product_bolt.write({"list_price": 99.99}) diff --git a/connector_onshape/tests/test_webhook.py b/connector_onshape/tests/test_webhook.py new file mode 100644 index 000000000..c0ff4becf --- /dev/null +++ b/connector_onshape/tests/test_webhook.py @@ -0,0 +1,91 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import hashlib +import hmac + +from .common import OnshapeTestCase + + +class TestWebhookController(OnshapeTestCase): + """Test the Onshape webhook controller.""" + + def test_validate_signature_correct(self): + from ..controllers.webhook import OnshapeWebhookController + + controller = OnshapeWebhookController() + secret = "test_webhook_secret" + body = b'{"event": "test"}' + expected_sig = hmac.new( + secret.encode("utf-8"), body, digestmod=hashlib.sha256 + ).hexdigest() + + result = controller._validate_signature(body, secret, expected_sig) + self.assertTrue(result) + + def test_validate_signature_incorrect(self): + from ..controllers.webhook import OnshapeWebhookController + + controller = OnshapeWebhookController() + result = controller._validate_signature( + b'{"event": "test"}', "secret", "wrong_signature" + ) + self.assertFalse(result) + + def test_event_handler_routing(self): + from ..controllers.webhook import OnshapeWebhookController + + controller = OnshapeWebhookController() + + handler = controller._get_event_handler("onshape.model.lifecycle.metadata") + self.assertIsNotNone(handler) + + handler = controller._get_event_handler("onshape.workflow.transition") + self.assertIsNotNone(handler) + + handler = controller._get_event_handler("onshape.revision.created") + self.assertIsNotNone(handler) + + handler = controller._get_event_handler("unknown.event") + self.assertIsNone(handler) + + def test_workflow_transition_updates_state(self): + doc = self._create_mock_document() + binding = self.env["onshape.product.product"].create( + { + "odoo_id": self.product_bolt.id, + "backend_id": self.backend.id, + "external_id": "abc123/elem_001/part_001", + "onshape_document_id": doc.id, + "onshape_state": "in_progress", + } + ) + + from ..controllers.webhook import OnshapeWebhookController + + controller = OnshapeWebhookController() + payload = { + "documentId": doc.onshape_document_id, + "transitionName": "Release", + } + controller._handle_workflow_transition(self.backend, payload) + self.assertEqual(binding.onshape_state, "released") + + def test_revision_created_marks_released(self): + doc = self._create_mock_document() + binding = self.env["onshape.product.product"].create( + { + "odoo_id": self.product_bolt.id, + "backend_id": self.backend.id, + "external_id": "abc123/elem_001/part_001", + "onshape_document_id": doc.id, + "onshape_state": "in_progress", + } + ) + + from ..controllers.webhook import OnshapeWebhookController + + controller = OnshapeWebhookController() + payload = {"documentId": doc.onshape_document_id} + controller._handle_revision_created(self.backend, payload) + self.assertEqual(binding.onshape_state, "released") diff --git a/connector_onshape/tests/test_wizard.py b/connector_onshape/tests/test_wizard.py new file mode 100644 index 000000000..8ebfea4d8 --- /dev/null +++ b/connector_onshape/tests/test_wizard.py @@ -0,0 +1,63 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.exceptions import UserError + +from .common import OnshapeTestCase + + +class TestImportWizard(OnshapeTestCase): + def test_default_backend(self): + wizard = self.env["onshape.import.wizard"].create({"import_type": "documents"}) + self.assertEqual(wizard.backend_id, self.backend) + + def test_import_requires_active_backend(self): + draft_backend = self.env["onshape.backend"].create( + { + "name": "Draft", + "base_url": "https://cad.onshape.com", + "auth_mode": "hmac", + "api_key": "k", + "api_secret": "s", + "state": "draft", + } + ) + wizard = self.env["onshape.import.wizard"].create( + { + "backend_id": draft_backend.id, + "import_type": "documents", + } + ) + with self.assertRaises(UserError): + wizard.action_import() + + @patch.object( + type( + OnshapeTestCase.env["onshape.backend"] + if hasattr(OnshapeTestCase, "env") + else object + ), + "action_import_documents", + ) + def test_documents_only_import(self, mock_import): + # Just verify the wizard can be created and doesn't crash + wizard = self.env["onshape.import.wizard"].create( + { + "backend_id": self.backend.id, + "import_type": "documents", + } + ) + self.assertEqual(wizard.import_type, "documents") + + def test_auto_create_flag_propagation(self): + self.backend.write({"auto_create_products": False}) + wizard = self.env["onshape.import.wizard"].create( + { + "backend_id": self.backend.id, + "import_type": "products", + "auto_create_products": True, + } + ) + self.assertTrue(wizard.auto_create_products) diff --git a/connector_onshape/views/mrp_bom_views.xml b/connector_onshape/views/mrp_bom_views.xml new file mode 100644 index 000000000..9a8a8cc43 --- /dev/null +++ b/connector_onshape/views/mrp_bom_views.xml @@ -0,0 +1,87 @@ + + + + + + mrp.bom.form.onshape + mrp.bom + + +
+ +
+ + + + +
+ + + + Onshape BOM Bindings + onshape.mrp.bom + tree,form + + + + + onshape.mrp.bom.tree + onshape.mrp.bom + + + + + + + + + + + + + + + onshape.mrp.bom.form + onshape.mrp.bom + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + +
diff --git a/connector_onshape/views/onshape_backend_views.xml b/connector_onshape/views/onshape_backend_views.xml new file mode 100644 index 000000000..a727dddc6 --- /dev/null +++ b/connector_onshape/views/onshape_backend_views.xml @@ -0,0 +1,199 @@ + + + + + + onshape.backend.form + onshape.backend + +
+
+
+ +
+ + + +
+
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + onshape.backend.tree + onshape.backend + + + + + + + + + + + + + + + + Onshape Backends + onshape.backend + tree,form + + + + + + + + + + + + + +
diff --git a/connector_onshape/views/onshape_document_views.xml b/connector_onshape/views/onshape_document_views.xml new file mode 100644 index 000000000..cef6273da --- /dev/null +++ b/connector_onshape/views/onshape_document_views.xml @@ -0,0 +1,143 @@ + + + + + + onshape.document.form + onshape.document + +
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + onshape.document.tree + onshape.document + + + + + + + + + + + + + + + onshape.document.search + onshape.document + + + + + + + + + + + + + + + + + + + Onshape Documents + onshape.document + tree,form + + + + + +
diff --git a/connector_onshape/views/onshape_product_views.xml b/connector_onshape/views/onshape_product_views.xml new file mode 100644 index 000000000..cffb3122e --- /dev/null +++ b/connector_onshape/views/onshape_product_views.xml @@ -0,0 +1,132 @@ + + + + + + onshape.product.product.form + onshape.product.product + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + onshape.product.product.tree + onshape.product.product + + + + + + + + + + + + + + + + + onshape.product.product.search + onshape.product.product + + + + + + + + + + + + + + + + + + + + + + Onshape Product Bindings + onshape.product.product + tree,form + + + + + +
diff --git a/connector_onshape/views/product_template_views.xml b/connector_onshape/views/product_template_views.xml new file mode 100644 index 000000000..a4a190d68 --- /dev/null +++ b/connector_onshape/views/product_template_views.xml @@ -0,0 +1,72 @@ + + + + + + product.template.form.onshape + product.template + + +
+ +
+
+
+ + + + product.product.form.onshape + product.product + + +
+ +
+ + + + + + +
+
+ + + + product.product.tree.onshape + product.product + + + + + + + + +
diff --git a/connector_onshape/wizards/__init__.py b/connector_onshape/wizards/__init__.py new file mode 100644 index 000000000..75322de75 --- /dev/null +++ b/connector_onshape/wizards/__init__.py @@ -0,0 +1 @@ +from . import onshape_import_wizard diff --git a/connector_onshape/wizards/onshape_import_wizard.py b/connector_onshape/wizards/onshape_import_wizard.py new file mode 100644 index 000000000..351f437e0 --- /dev/null +++ b/connector_onshape/wizards/onshape_import_wizard.py @@ -0,0 +1,79 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class OnshapeImportWizard(models.TransientModel): + _name = "onshape.import.wizard" + _description = "Onshape Import Wizard" + + backend_id = fields.Many2one( + "onshape.backend", + string="Backend", + required=True, + default=lambda self: self._default_backend_id(), + ) + import_type = fields.Selection( + [ + ("documents", "Documents Only"), + ("products", "Documents + Products"), + ("boms", "Documents + Products + BOMs"), + ("full", "Full Sync (All)"), + ], + required=True, + default="products", + ) + auto_create_products = fields.Boolean( + string="Auto-create Products", + help="Create new Odoo products for unmatched Onshape parts.", + ) + + @api.model + def _default_backend_id(self): + return self.env["onshape.backend"].search([("state", "=", "active")], limit=1) + + def action_import(self): + self.ensure_one() + backend = self.backend_id + if backend.state != "active": + raise UserError(_("Backend must be active. Check credentials first.")) + + if self.auto_create_products: + backend.write({"auto_create_products": True}) + + if self.import_type in ("documents", "products", "boms", "full"): + backend.action_import_documents() + + if self.import_type in ("products", "boms", "full"): + backend.action_import_products() + + if self.import_type in ("boms", "full"): + backend.action_import_boms() + + if self.import_type == "full": + backend.action_export_part_numbers() + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Import Started"), + "message": _( + "Import jobs have been queued. " "Check the job queue for progress." + ), + "type": "success", + "sticky": False, + "next": { + "type": "ir.actions.act_window", + "res_model": "onshape.backend", + "res_id": backend.id, + "view_mode": "form", + }, + }, + } diff --git a/connector_onshape/wizards/onshape_import_wizard_views.xml b/connector_onshape/wizards/onshape_import_wizard_views.xml new file mode 100644 index 000000000..e61913677 --- /dev/null +++ b/connector_onshape/wizards/onshape_import_wizard_views.xml @@ -0,0 +1,37 @@ + + + + + onshape.import.wizard.form + onshape.import.wizard + +
+ + + + + +
+
+
+
+
+ + + Import from Onshape + onshape.import.wizard + form + new + + +
diff --git a/setup/connector_onshape/odoo/addons/connector_onshape b/setup/connector_onshape/odoo/addons/connector_onshape new file mode 120000 index 000000000..5c7d79720 --- /dev/null +++ b/setup/connector_onshape/odoo/addons/connector_onshape @@ -0,0 +1 @@ +../../../../connector_onshape \ No newline at end of file diff --git a/setup/connector_onshape/setup.py b/setup/connector_onshape/setup.py new file mode 100644 index 000000000..00a90304a --- /dev/null +++ b/setup/connector_onshape/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=["setuptools-odoo"], + odoo_addon=True, +) From 25b86ad31bbd3c02af36d856b015ed4724d6783f Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Wed, 25 Feb 2026 10:02:49 -0700 Subject: [PATCH 2/5] [IMP] connector_onshape: extend metadata import with mass, appearance, revision, vendor, project Add comprehensive metadata extraction from Onshape API: - 12 new fields on binding: description, appearance, revision, mass, volume, surface_area, author, designer, vendor, project, custom_properties - Mass properties endpoint (mass/volume/surface area from Onshape) - Property-to-field mapping via PROPERTY_FIELD_MAP dict - Custom/unknown properties stored as JSON - Material/appearance fallback from part data when not in metadata - Export mapper uses Onshape property names as keys - Updated views with Physical Properties, People & Project groups - 4 new test methods for metadata, mass, custom props, fallback Co-Authored-By: Claude Opus 4.6 --- connector_onshape/components/adapter.py | 16 ++ connector_onshape/components/exporter.py | 16 +- connector_onshape/components/importer.py | 22 +++ connector_onshape/components/mapper.py | 144 ++++++++++++++---- .../models/onshape_product_product.py | 46 ++++++ connector_onshape/tests/common.py | 29 +++- .../tests/test_export_product.py | 8 +- .../tests/test_import_product.py | 37 +++++ .../views/onshape_product_views.xml | 32 ++++ 9 files changed, 304 insertions(+), 46 deletions(-) diff --git a/connector_onshape/components/adapter.py b/connector_onshape/components/adapter.py index 20d9fe1e8..f44d575bd 100644 --- a/connector_onshape/components/adapter.py +++ b/connector_onshape/components/adapter.py @@ -258,6 +258,22 @@ def read_parts(self, document_id, workspace_id, element_id): f"/api/v6/parts/d/{document_id}/w/{workspace_id}" f"/e/{element_id}", ) + def read_mass_properties(self, document_id, workspace_id, element_id, part_id=None): + """Get computed mass properties (mass, volume, surface area). + + Returns dict with 'bodies' key containing per-body mass data. + Each body has 'mass' (kg), 'volume' (m³), 'periphery' (m²). + """ + params = {} + if part_id: + params["partId"] = part_id + return self._request( + "GET", + f"/api/v6/parts/d/{document_id}/w/{workspace_id}" + f"/e/{element_id}/massproperties", + query_params=params if params else None, + ) + def read_part_metadata(self, document_id, workspace_id, element_id): return self._request( "GET", diff --git a/connector_onshape/components/exporter.py b/connector_onshape/components/exporter.py index 7c33b1de6..b4ef5701f 100644 --- a/connector_onshape/components/exporter.py +++ b/connector_onshape/components/exporter.py @@ -52,8 +52,6 @@ def run(self, binding): return export_values = mapper.map_record(binding) - part_number = export_values.get("part_number") - description = export_values.get("description") items = meta.get("items", []) if not items: @@ -67,21 +65,15 @@ def run(self, binding): ): continue + # Build update list: match export values to existing property IDs properties_update = [] for prop in part_item.get("properties", []): prop_name = prop.get("name", "") - if prop_name == "Part Number" and part_number: + if prop_name in export_values and export_values[prop_name]: properties_update.append( { "propertyId": prop["propertyId"], - "value": part_number, - } - ) - elif prop_name == "Description" and description: - properties_update.append( - { - "propertyId": prop["propertyId"], - "value": description, + "value": str(export_values[prop_name]), } ) @@ -102,5 +94,5 @@ def run(self, binding): _logger.info( "Exported product data for binding %s (SKU: %s)", binding.id, - part_number, + export_values.get("Part Number", ""), ) diff --git a/connector_onshape/components/importer.py b/connector_onshape/components/importer.py index 05941d66f..b93ede083 100644 --- a/connector_onshape/components/importer.py +++ b/connector_onshape/components/importer.py @@ -227,6 +227,7 @@ class OnshapeProductRecordImporter(Component): def run(self, backend, document, element, part_data): binder = self.component(usage="binder") mapper = self.component(usage="import.mapper") + adapter = self.component(usage="backend.adapter") part_id = part_data.get("partId", "") external_id = binder.make_compound_id( @@ -235,6 +236,9 @@ def run(self, backend, document, element, part_data): part_id, ) + # Enrich part_data with mass properties if available + self._enrich_mass_properties(adapter, document, element, part_id, part_data) + existing = binder.to_internal(external_id) if existing: # Update existing binding @@ -290,6 +294,24 @@ def run(self, backend, document, element, part_data): binding = self.env["onshape.product.product"].create(vals) return binding + def _enrich_mass_properties(self, adapter, document, element, part_id, part_data): + """Fetch mass properties from Onshape and inject into part_data.""" + try: + mass_data = adapter.read_mass_properties( + document.onshape_document_id, + document.onshape_default_workspace_id, + element.onshape_element_id, + part_id=part_id, + ) + if mass_data and isinstance(mass_data, dict): + part_data["mass_properties"] = mass_data + except Exception: + _logger.debug( + "Could not fetch mass properties for %s/%s", + document.onshape_document_id, + part_id, + ) + def _match_or_create_product(self, backend, part_data, vals): """Try to match to an existing product, or create a new one.""" mapper = self.component(usage="import.mapper") diff --git a/connector_onshape/components/mapper.py b/connector_onshape/components/mapper.py index 973f24d8b..0738840e1 100644 --- a/connector_onshape/components/mapper.py +++ b/connector_onshape/components/mapper.py @@ -1,6 +1,7 @@ # Copyright 2024 Kencove Farm Fence Supplies # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import json import logging import re @@ -13,11 +14,33 @@ # Version suffix pattern (e.g., .0001, .0002) VERSION_SUFFIX_RE = re.compile(r"\.\d{4}$") +# Standard Onshape metadata property names we recognize +KNOWN_PROPERTIES = { + "Part Number", + "Name", + "Description", + "Material", + "Appearance", + "Revision", + "Vendor", + "Project", + "Title 1", + "Title 2", + "Title 3", +} + class OnshapeProductImportMapper(Component): """Map Onshape part data to onshape.product.product fields. - Includes the 4-strategy SKU matching ported from + Extracts all available metadata from Onshape API responses including: + - Standard properties: Part Number, Description, Material, Appearance, + Revision, Vendor, Project + - Mass properties: mass, volume, surface_area (from separate API call) + - Document owner / author / designer + - Custom properties (stored as JSON) + + Also includes the 4-strategy SKU matching ported from match_cad_to_odoo.py lines 60-92. """ @@ -26,33 +49,91 @@ class OnshapeProductImportMapper(Component): _usage = "import.mapper" _apply_on = "onshape.product.product" - def map_record(self, part_data, document=None, element=None): - part_name = part_data.get("name", "") - part_number = "" - material = "" - state = "in_progress" + # Map of Onshape property name → binding field name + PROPERTY_FIELD_MAP = { + "Part Number": "onshape_part_number", + "Description": "onshape_description", + "Material": "onshape_material", + "Appearance": "onshape_appearance", + "Revision": "onshape_revision", + "Vendor": "onshape_vendor", + "Project": "onshape_project", + } - # Extract from properties if available + def map_record(self, part_data, document=None, element=None): + vals = { + "onshape_name": part_data.get("name", ""), + "onshape_state": "in_progress", + } + # Initialize all mapped fields to empty + for field_name in self.PROPERTY_FIELD_MAP.values(): + vals[field_name] = "" + vals.update( + { + "onshape_author": "", + "onshape_designer": "", + } + ) + + custom_props = self._extract_properties(part_data, vals) + self._apply_fallbacks(part_data, vals) + self._extract_mass_properties(part_data, vals) + + if document and document.owner: + vals["onshape_author"] = document.owner + + if custom_props: + vals["onshape_custom_properties"] = json.dumps( + custom_props, ensure_ascii=False + ) + + return vals + + def _extract_properties(self, part_data, vals): + """Extract standard and custom properties from metadata.""" + custom_props = {} for prop in part_data.get("properties", []): name = prop.get("name", "") value = prop.get("value", "") - if name == "Part Number": - part_number = value - elif name == "Material": - material = value - elif name == "Description": - pass # we use part_name - - # Extract from appearance/material if in part data directly - if not material: - material = part_data.get("material", {}).get("displayName", "") - - return { - "onshape_name": part_name, - "onshape_part_number": part_number, - "onshape_material": material, - "onshape_state": state, - } + if not value: + continue + field_name = self.PROPERTY_FIELD_MAP.get(name) + if field_name: + vals[field_name] = value + elif name not in KNOWN_PROPERTIES: + custom_props[name] = value + return custom_props + + def _apply_fallbacks(self, part_data, vals): + """Apply fallback values from part data for material and appearance.""" + if not vals["onshape_material"]: + mat = part_data.get("material", {}) + if isinstance(mat, dict): + vals["onshape_material"] = mat.get("displayName", "") + if not vals["onshape_appearance"]: + appearance = part_data.get("appearance", {}) + if isinstance(appearance, dict): + vals["onshape_appearance"] = appearance.get("name", "") + + @staticmethod + def _extract_mass_properties(part_data, vals): + """Extract mass, volume, surface area from injected mass properties.""" + bodies = part_data.get("mass_properties", {}).get("bodies", {}) + if not bodies: + return + total_mass = 0.0 + total_volume = 0.0 + total_area = 0.0 + for body_data in bodies.values(): + total_mass += body_data.get("mass", [0.0])[0] + total_volume += body_data.get("volume", [0.0])[0] + total_area += body_data.get("periphery", [0.0])[0] + if total_mass: + vals["onshape_mass"] = total_mass + if total_volume: + vals["onshape_volume"] = total_volume + if total_area: + vals["onshape_surface_area"] = total_area def match_product(self, part_data): """Try to match an Onshape part to an existing Odoo product. @@ -120,7 +201,10 @@ def match_product(self, part_data): class OnshapeProductExportMapper(Component): - """Map Odoo product data to Onshape metadata fields.""" + """Map Odoo product data to Onshape metadata fields. + + Exports Odoo product fields to Onshape standard metadata properties. + """ _name = "onshape.product.export.mapper" _inherit = "onshape.base" @@ -128,7 +212,11 @@ class OnshapeProductExportMapper(Component): _apply_on = "onshape.product.product" def map_record(self, binding): - return { - "part_number": binding.odoo_id.default_code or "", - "description": binding.odoo_id.name or "", + vals = { + "Part Number": binding.odoo_id.default_code or "", + "Description": binding.odoo_id.name or "", } + # Include weight from Odoo if set (export back to Onshape) + if binding.odoo_id.weight: + vals["Weight"] = str(binding.odoo_id.weight) + return vals diff --git a/connector_onshape/models/onshape_product_product.py b/connector_onshape/models/onshape_product_product.py index 853db3d5b..54bc3681a 100644 --- a/connector_onshape/models/onshape_product_product.py +++ b/connector_onshape/models/onshape_product_product.py @@ -43,7 +43,53 @@ class OnshapeProductProduct(models.Model): string="Onshape Part Name", help="Part name as stored in Onshape.", ) + onshape_description = fields.Char( + help="Description property from Onshape metadata.", + ) onshape_material = fields.Char() + onshape_appearance = fields.Char( + string="Appearance", + help="Appearance / color / finish from Onshape.", + ) + onshape_revision = fields.Char( + string="Revision", + help="Revision identifier from Onshape release management.", + ) + onshape_mass = fields.Float( + string="Mass (kg)", + digits=(16, 6), + help="Computed mass from Onshape mass properties (kg).", + ) + onshape_volume = fields.Float( + string="Volume (m³)", + digits=(16, 9), + help="Computed volume from Onshape mass properties (m³).", + ) + onshape_surface_area = fields.Float( + string="Surface Area (m²)", + digits=(16, 6), + help="Computed surface area from Onshape mass properties (m²).", + ) + onshape_author = fields.Char( + string="Author", + help="Original author from Inventor metadata or Onshape document owner.", + ) + onshape_designer = fields.Char( + string="Designer", + help="Last designer/editor from Inventor Design Tracking metadata.", + ) + onshape_vendor = fields.Char( + string="Vendor", + help="Vendor property from Onshape metadata.", + ) + onshape_project = fields.Char( + string="Project", + help="Project property from Onshape metadata.", + ) + onshape_custom_properties = fields.Text( + string="Custom Properties (JSON)", + help="Additional Onshape custom properties stored as JSON.", + ) onshape_state = fields.Selection( [ ("in_progress", "In Progress"), diff --git a/connector_onshape/tests/common.py b/connector_onshape/tests/common.py index e35d0f344..8a5a519f0 100644 --- a/connector_onshape/tests/common.py +++ b/connector_onshape/tests/common.py @@ -40,20 +40,44 @@ "name": "KF-BOLT-001", "properties": [ {"name": "Part Number", "propertyId": "pn_001", "value": ""}, - {"name": "Description", "propertyId": "desc_001", "value": ""}, + {"name": "Description", "propertyId": "desc_001", "value": "Hex bolt"}, {"name": "Material", "propertyId": "mat_001", "value": "Steel"}, + {"name": "Appearance", "propertyId": "app_001", "value": "Zinc Plated"}, + {"name": "Vendor", "propertyId": "vnd_001", "value": "Fastenal"}, + {"name": "Project", "propertyId": "prj_001", "value": "Fence Kit A"}, + {"name": "Revision", "propertyId": "rev_001", "value": "B"}, + { + "name": "Custom Finish", + "propertyId": "cf_001", + "value": "Hot-dip galvanized", + }, ], + "material": {"displayName": "Steel, Mild"}, }, { "partId": "part_002", "name": "90185A632", "properties": [ {"name": "Part Number", "propertyId": "pn_002", "value": "90185A632"}, - {"name": "Description", "propertyId": "desc_002", "value": "McMaster bolt"}, + { + "name": "Description", + "propertyId": "desc_002", + "value": "McMaster bolt", + }, ], }, ] +MOCK_MASS_PROPERTIES = { + "bodies": { + "body_001": { + "mass": [0.045], + "volume": [5.73e-6], + "periphery": [0.00234], + } + } +} + MOCK_PART_METADATA = { "items": [ { @@ -162,6 +186,7 @@ def _mock_adapter(self): adapter.read_document_elements.return_value = MOCK_ELEMENTS adapter.read_parts.return_value = MOCK_PARTS adapter.read_part_metadata.return_value = MOCK_PART_METADATA + adapter.read_mass_properties.return_value = MOCK_MASS_PROPERTIES adapter.read_assembly_bom.return_value = MOCK_ASSEMBLY_BOM adapter.write_part_metadata.return_value = {} adapter.read_thumbnail.return_value = None diff --git a/connector_onshape/tests/test_export_product.py b/connector_onshape/tests/test_export_product.py index 9f4d99010..8d5bd8bfb 100644 --- a/connector_onshape/tests/test_export_product.py +++ b/connector_onshape/tests/test_export_product.py @@ -23,8 +23,8 @@ def test_export_mapper_values(self): mapper = work.component(usage="export.mapper") vals = mapper.map_record(binding) - self.assertEqual(vals["part_number"], "KF-BOLT-001") - self.assertEqual(vals["description"], "Hex Bolt 3/8-16") + self.assertEqual(vals["Part Number"], "KF-BOLT-001") + self.assertEqual(vals["Description"], "Hex Bolt 3/8-16") def test_export_mapper_empty_sku(self): product_no_sku = self.env["product.product"].create( @@ -46,8 +46,8 @@ def test_export_mapper_empty_sku(self): mapper = work.component(usage="export.mapper") vals = mapper.map_record(binding) - self.assertEqual(vals["part_number"], "") - self.assertEqual(vals["description"], "No SKU Product") + self.assertEqual(vals["Part Number"], "") + self.assertEqual(vals["Description"], "No SKU Product") class TestBinder(OnshapeTestCase): diff --git a/connector_onshape/tests/test_import_product.py b/connector_onshape/tests/test_import_product.py index 4d47f8a5d..074667eaf 100644 --- a/connector_onshape/tests/test_import_product.py +++ b/connector_onshape/tests/test_import_product.py @@ -75,6 +75,43 @@ def test_map_record_extracts_fields(self): vals = mapper.map_record(MOCK_PARTS[0]) self.assertEqual(vals["onshape_name"], "KF-BOLT-001") self.assertEqual(vals["onshape_material"], "Steel") + self.assertEqual(vals["onshape_description"], "Hex bolt") + self.assertEqual(vals["onshape_appearance"], "Zinc Plated") + self.assertEqual(vals["onshape_vendor"], "Fastenal") + self.assertEqual(vals["onshape_project"], "Fence Kit A") + self.assertEqual(vals["onshape_revision"], "B") + + def test_map_record_custom_properties(self): + """Custom/unknown properties are stored as JSON.""" + import json + + mapper = self._get_mapper() + vals = mapper.map_record(MOCK_PARTS[0]) + custom = json.loads(vals.get("onshape_custom_properties", "{}")) + self.assertEqual(custom.get("Custom Finish"), "Hot-dip galvanized") + + def test_map_record_mass_properties(self): + """Mass properties injected by importer are mapped.""" + from ..tests.common import MOCK_MASS_PROPERTIES + + mapper = self._get_mapper() + part_data = dict(MOCK_PARTS[0]) + part_data["mass_properties"] = MOCK_MASS_PROPERTIES + vals = mapper.map_record(part_data) + self.assertAlmostEqual(vals["onshape_mass"], 0.045, places=4) + self.assertAlmostEqual(vals["onshape_volume"], 5.73e-6, places=10) + self.assertAlmostEqual(vals["onshape_surface_area"], 0.00234, places=6) + + def test_map_record_material_fallback(self): + """Material falls back to part data displayName if not in properties.""" + mapper = self._get_mapper() + part_data = { + "name": "Test Part", + "properties": [], + "material": {"displayName": "Aluminum 6061"}, + } + vals = mapper.map_record(part_data) + self.assertEqual(vals["onshape_material"], "Aluminum 6061") def test_map_record_with_part_number(self): mapper = self._get_mapper() diff --git a/connector_onshape/views/onshape_product_views.xml b/connector_onshape/views/onshape_product_views.xml index cffb3122e..e4035fd65 100644 --- a/connector_onshape/views/onshape_product_views.xml +++ b/connector_onshape/views/onshape_product_views.xml @@ -22,11 +22,27 @@ + + + + + + + + + + + + + + + + + + + @@ -56,6 +82,9 @@ decoration-danger="onshape_state == 'obsolete'" /> + + + @@ -71,6 +100,9 @@ + + + Date: Wed, 25 Feb 2026 13:20:27 -0700 Subject: [PATCH 3/5] fix: address CodeRabbit review findings on connector_onshape - adapter: use secrets instead of random for HMAC nonce, guard oauth2_token JSON parse, guard Retry-After header, extract _log_rate_limit and _check_retryable to reduce complexity - binder: validate input in split_compound_id (raise ValueError) - exporter: update sync_date even when metadata items empty - importer: BOM "Try by name" searches product name not default_code - mapper: guard against empty mass property lists from API - onshape_product_product: add workspace_id to @api.depends, guard URL requiring workspace + element IDs - product_template: add onshape_document_id to @api.depends - onshape_document: use name_get() for Odoo 16 compat - wizard: always sync auto_create_products boolean - webhook: warn on missing secret, narrow binding scope with _find_bindings helper, use elementId/partId when available - backend: cron methods use with_delay() for async processing - views: mask oauth2_token with password=True - data: remove invalid related_action_enable from job functions - tests: add malformed compound ID tests, fix mcmaster assertion Co-Authored-By: Claude Opus 4.6 --- connector_onshape/components/adapter.py | 104 ++++++++++-------- connector_onshape/components/binder.py | 4 +- connector_onshape/components/exporter.py | 1 + connector_onshape/components/importer.py | 2 +- connector_onshape/components/mapper.py | 6 +- connector_onshape/controllers/webhook.py | 44 ++++---- .../data/queue_job_function_data.xml | 4 - connector_onshape/models/onshape_backend.py | 6 +- connector_onshape/models/onshape_document.py | 10 +- .../models/onshape_product_product.py | 14 ++- connector_onshape/models/product_template.py | 5 +- .../tests/test_export_product.py | 12 ++ .../tests/test_import_product.py | 2 +- .../views/onshape_backend_views.xml | 2 +- .../wizards/onshape_import_wizard.py | 3 +- 15 files changed, 126 insertions(+), 93 deletions(-) diff --git a/connector_onshape/components/adapter.py b/connector_onshape/components/adapter.py index f44d575bd..52f30b8f0 100644 --- a/connector_onshape/components/adapter.py +++ b/connector_onshape/components/adapter.py @@ -6,7 +6,7 @@ import hmac import json import logging -import random +import secrets import string import time from datetime import datetime @@ -55,9 +55,8 @@ def _hmac_headers(self, method, path, query_params=None, content_type=""): backend = self._get_backend() method = method.upper() date = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT") - nonce = "".join( - random.choice(string.ascii_letters + string.digits) for _ in range(25) - ) + alphabet = string.ascii_letters + string.digits + nonce = "".join(secrets.choice(alphabet) for _ in range(25)) canonical_query = self._canonical_query(query_params) string_to_sign = ( @@ -94,7 +93,11 @@ def _hmac_headers(self, method, path, query_params=None, content_type=""): def _oauth2_headers(self, content_type=""): backend = self._get_backend() - token_data = json.loads(backend.oauth2_token or "{}") + try: + token_data = json.loads(backend.oauth2_token or "{}") + except (ValueError, TypeError): + _logger.error("oauth2_token for backend %s is not valid JSON", backend.id) + token_data = {} access_token = token_data.get("access_token", "") headers = { "Authorization": f"Bearer {access_token}", @@ -117,6 +120,52 @@ def _get_auth_headers(self, method, path, query_params=None, content_type=""): # --- HTTP request with retry --- + def _log_rate_limit(self, resp): + """Warn when Onshape rate-limit headroom is low.""" + remaining = resp.headers.get("X-Rate-Limit-Remaining") + if remaining is None: + return + try: + remaining_int = int(remaining) + if remaining_int < 10: + _logger.warning("Onshape rate limit low: %s remaining", remaining_int) + except ValueError: + _logger.debug("Non-integer rate limit header: %s", remaining) + + def _check_retryable(self, resp, attempt): + """Return wait seconds if request should be retried, else None.""" + if resp.status_code == 402: + _logger.error( + "Onshape API quota exhausted (402). " "Consider switching to OAuth2." + ) + raise OnshapeQuotaError( + "Onshape API quota exhausted. " + "Switch to OAuth2 App Store authentication." + ) + if resp.status_code == 429: + wait = RETRY_BACKOFF * (attempt + 1) + retry_after = resp.headers.get("Retry-After") + if retry_after: + try: + wait = int(retry_after) + except (TypeError, ValueError): + _logger.debug( + "Unexpected Retry-After header %r; using backoff %ss", + retry_after, + wait, + ) + _logger.warning("Onshape rate limited (429), waiting %ss", wait) + return wait + if resp.status_code >= 500: + wait = RETRY_BACKOFF * (attempt + 1) + _logger.warning( + "Onshape server error %s, retry in %ss", + resp.status_code, + wait, + ) + return wait + return None + def _request(self, method, path, query_params=None, json_body=None, raw=False): """Make an authenticated API request with retry and rate-limit handling. @@ -144,54 +193,15 @@ def _request(self, method, path, query_params=None, json_body=None, raw=False): timeout=30, ) last_resp = resp + self._log_rate_limit(resp) - # Track rate limit - remaining = resp.headers.get("X-Rate-Limit-Remaining") - if remaining is not None: - try: - remaining_int = int(remaining) - if remaining_int < 10: - _logger.warning( - "Onshape rate limit low: %s remaining", - remaining_int, - ) - except ValueError: - _logger.debug("Non-integer rate limit header: %s", remaining) - - if resp.status_code == 402: - _logger.error( - "Onshape API quota exhausted (402). " - "Consider switching to OAuth2." - ) - raise OnshapeQuotaError( - "Onshape API quota exhausted. " - "Switch to OAuth2 App Store authentication." - ) - - if resp.status_code == 429: - retry_after = resp.headers.get("Retry-After") - wait = ( - int(retry_after) - if retry_after - else RETRY_BACKOFF * (attempt + 1) - ) - _logger.warning("Onshape rate limited (429), waiting %ss", wait) - time.sleep(wait) - continue - - if resp.status_code >= 500: - wait = RETRY_BACKOFF * (attempt + 1) - _logger.warning( - "Onshape server error %s, retry in %ss", - resp.status_code, - wait, - ) + wait = self._check_retryable(resp, attempt) + if wait is not None: time.sleep(wait) continue if raw: return resp - resp.raise_for_status() if resp.status_code == 204: return {} diff --git a/connector_onshape/components/binder.py b/connector_onshape/components/binder.py index 87f718757..6c5a2c6ff 100644 --- a/connector_onshape/components/binder.py +++ b/connector_onshape/components/binder.py @@ -57,6 +57,8 @@ def make_compound_id(document_id, element_id, part_id=None): @staticmethod def split_compound_id(external_id): + if not external_id: + raise ValueError("external_id must be a non-empty string") parts = external_id.split("/") if len(parts) == 3: return { @@ -66,4 +68,4 @@ def split_compound_id(external_id): } if len(parts) == 2: return {"document_id": parts[0], "element_id": parts[1]} - return {"document_id": external_id} + raise ValueError("Cannot parse compound ID: %r" % external_id) diff --git a/connector_onshape/components/exporter.py b/connector_onshape/components/exporter.py index b4ef5701f..7404bf33c 100644 --- a/connector_onshape/components/exporter.py +++ b/connector_onshape/components/exporter.py @@ -55,6 +55,7 @@ def run(self, binding): items = meta.get("items", []) if not items: + binding.write({"sync_date": fields.Datetime.now()}) return for part_item in items: diff --git a/connector_onshape/components/importer.py b/connector_onshape/components/importer.py index b93ede083..eea7a286b 100644 --- a/connector_onshape/components/importer.py +++ b/connector_onshape/components/importer.py @@ -520,7 +520,7 @@ def _find_component_product(self, backend, bom_item): # Try by name if item_name: product = self.env["product.product"].search( - [("default_code", "=", item_name)], limit=1 + [("name", "=", item_name)], limit=1 ) if product: return product diff --git a/connector_onshape/components/mapper.py b/connector_onshape/components/mapper.py index 0738840e1..48b547327 100644 --- a/connector_onshape/components/mapper.py +++ b/connector_onshape/components/mapper.py @@ -125,9 +125,9 @@ def _extract_mass_properties(part_data, vals): total_volume = 0.0 total_area = 0.0 for body_data in bodies.values(): - total_mass += body_data.get("mass", [0.0])[0] - total_volume += body_data.get("volume", [0.0])[0] - total_area += body_data.get("periphery", [0.0])[0] + total_mass += (body_data.get("mass") or [0.0])[0] + total_volume += (body_data.get("volume") or [0.0])[0] + total_area += (body_data.get("periphery") or [0.0])[0] if total_mass: vals["onshape_mass"] = total_mass if total_volume: diff --git a/connector_onshape/controllers/webhook.py b/connector_onshape/controllers/webhook.py index 836f7aefd..2efbb09be 100644 --- a/connector_onshape/controllers/webhook.py +++ b/connector_onshape/controllers/webhook.py @@ -42,6 +42,12 @@ def webhook(self, backend_id, **kwargs): # Validate HMAC signature raw_body = request.httprequest.get_data() + if not backend.webhook_secret: + _logger.warning( + "Webhook secret not configured for backend %s. " + "Set webhook_secret to verify event authenticity.", + backend_id, + ) if backend.webhook_secret: signature = request.httprequest.headers.get( "X-Onshape-Webhook-Signature", "" @@ -139,16 +145,7 @@ def _handle_workflow_transition(self, backend, payload): if not mapped_state: return - bindings = ( - request.env["onshape.product.product"] - .sudo() - .search( - [ - ("backend_id", "=", backend.id), - ("onshape_document_id.onshape_document_id", "=", doc_id), - ] - ) - ) + bindings = self._find_bindings(backend, payload) if bindings: bindings.write({"onshape_state": mapped_state}) _logger.info( @@ -160,20 +157,25 @@ def _handle_workflow_transition(self, backend, payload): def _handle_revision_created(self, backend, payload): """Mark parts as released when a revision is created.""" - doc_id = payload.get("documentId", "") - bindings = ( - request.env["onshape.product.product"] - .sudo() - .search( - [ - ("backend_id", "=", backend.id), - ("onshape_document_id.onshape_document_id", "=", doc_id), - ] - ) - ) + bindings = self._find_bindings(backend, payload) if bindings: bindings.write({"onshape_state": "released"}) + def _find_bindings(self, backend, payload): + """Find product bindings matching the webhook payload scope.""" + doc_id = payload.get("documentId", "") + elem_id = payload.get("elementId", "") + part_id = payload.get("partId", "") + domain = [ + ("backend_id", "=", backend.id), + ("onshape_document_id.onshape_document_id", "=", doc_id), + ] + if elem_id: + domain.append(("onshape_element_id", "=", elem_id)) + if part_id: + domain.append(("onshape_part_id", "=", part_id)) + return request.env["onshape.product.product"].sudo().search(domain) + def _handle_version_created(self, backend, payload): """Log version creation (informational).""" doc_id = payload.get("documentId", "") diff --git a/connector_onshape/data/queue_job_function_data.xml b/connector_onshape/data/queue_job_function_data.xml index bbbf94375..ac205681f 100644 --- a/connector_onshape/data/queue_job_function_data.xml +++ b/connector_onshape/data/queue_job_function_data.xml @@ -5,7 +5,6 @@ action_import_documents - @@ -13,7 +12,6 @@ action_import_products - @@ -21,7 +19,6 @@ action_import_boms - @@ -29,7 +26,6 @@ export_record - diff --git a/connector_onshape/models/onshape_backend.py b/connector_onshape/models/onshape_backend.py index 7e5bc0702..431e002be 100644 --- a/connector_onshape/models/onshape_backend.py +++ b/connector_onshape/models/onshape_backend.py @@ -178,16 +178,16 @@ def action_open_bom_bindings(self): def cron_import_documents(self): backends = self.search([("state", "=", "active")]) for backend in backends: - backend.action_import_documents() + backend.with_delay().action_import_documents() @api.model def cron_import_products(self): backends = self.search([("state", "=", "active")]) for backend in backends: - backend.action_import_products() + backend.with_delay().action_import_products() @api.model def cron_import_boms(self): backends = self.search([("state", "=", "active")]) for backend in backends: - backend.action_import_boms() + backend.with_delay().action_import_boms() diff --git a/connector_onshape/models/onshape_document.py b/connector_onshape/models/onshape_document.py index b69e9b397..0f03d7f07 100644 --- a/connector_onshape/models/onshape_document.py +++ b/connector_onshape/models/onshape_document.py @@ -77,13 +77,15 @@ def _compute_onshape_url(self): else: rec.onshape_url = False - @api.depends("name", "onshape_document_id") - def _compute_display_name(self): + def name_get(self): + result = [] for rec in self: if rec.onshape_document_id: - rec.display_name = f"[{rec.onshape_document_id[:8]}] {rec.name}" + name = f"[{rec.onshape_document_id[:8]}] {rec.name}" else: - rec.display_name = rec.name + name = rec.name or "" + result.append((rec.id, name)) + return result class OnshapeDocumentElement(models.Model): diff --git a/connector_onshape/models/onshape_product_product.py b/connector_onshape/models/onshape_product_product.py index 54bc3681a..47d24196d 100644 --- a/connector_onshape/models/onshape_product_product.py +++ b/connector_onshape/models/onshape_product_product.py @@ -132,18 +132,24 @@ class OnshapeProductProduct(models.Model): @api.depends( "onshape_document_id.backend_id.base_url", "onshape_document_id.onshape_document_id", + "onshape_document_id.onshape_default_workspace_id", "onshape_element_id", ) def _compute_onshape_url(self): for rec in self: doc = rec.onshape_document_id - if doc and doc.backend_id.base_url and doc.onshape_document_id: - workspace = doc.onshape_default_workspace_id or "" - elem = rec.onshape_element_id or "" + if ( + doc + and doc.backend_id.base_url + and doc.onshape_document_id + and doc.onshape_default_workspace_id + and rec.onshape_element_id + ): rec.onshape_url = ( f"{doc.backend_id.base_url}" f"/documents/{doc.onshape_document_id}" - f"/w/{workspace}/e/{elem}" + f"/w/{doc.onshape_default_workspace_id}" + f"/e/{rec.onshape_element_id}" ) else: rec.onshape_url = False diff --git a/connector_onshape/models/product_template.py b/connector_onshape/models/product_template.py index 61f787ee3..d912b1c0c 100644 --- a/connector_onshape/models/product_template.py +++ b/connector_onshape/models/product_template.py @@ -12,7 +12,10 @@ class ProductTemplate(models.Model): compute="_compute_onshape_document_count", ) - @api.depends("product_variant_ids.onshape_bind_ids") + @api.depends( + "product_variant_ids.onshape_bind_ids", + "product_variant_ids.onshape_bind_ids.onshape_document_id", + ) def _compute_onshape_document_count(self): for rec in self: doc_ids = set() diff --git a/connector_onshape/tests/test_export_product.py b/connector_onshape/tests/test_export_product.py index 8d5bd8bfb..108a7e80d 100644 --- a/connector_onshape/tests/test_export_product.py +++ b/connector_onshape/tests/test_export_product.py @@ -76,3 +76,15 @@ def test_split_compound_id(self): result = OnshapeBinder.split_compound_id("doc1/elem1") self.assertEqual(result, {"document_id": "doc1", "element_id": "elem1"}) + + def test_split_compound_id_malformed(self): + from ..components.binder import OnshapeBinder + + with self.assertRaises(ValueError): + OnshapeBinder.split_compound_id("doc1") + + with self.assertRaises(ValueError): + OnshapeBinder.split_compound_id("") + + with self.assertRaises(ValueError): + OnshapeBinder.split_compound_id(None) diff --git a/connector_onshape/tests/test_import_product.py b/connector_onshape/tests/test_import_product.py index 074667eaf..3778e8f63 100644 --- a/connector_onshape/tests/test_import_product.py +++ b/connector_onshape/tests/test_import_product.py @@ -53,8 +53,8 @@ def test_mcmaster_catalog_match(self): "properties": [], } product, match_type = mapper.match_product(part_data) - # Should match via exact filename first (90185A632 is the default_code) self.assertEqual(product, self.product_mcmaster) + self.assertEqual(match_type, "mcmaster_catalog") def test_case_insensitive_match(self): mapper = self._get_mapper() diff --git a/connector_onshape/views/onshape_backend_views.xml b/connector_onshape/views/onshape_backend_views.xml index a727dddc6..3c058e05b 100644 --- a/connector_onshape/views/onshape_backend_views.xml +++ b/connector_onshape/views/onshape_backend_views.xml @@ -113,7 +113,7 @@ > - + diff --git a/connector_onshape/wizards/onshape_import_wizard.py b/connector_onshape/wizards/onshape_import_wizard.py index 351f437e0..e0d88655e 100644 --- a/connector_onshape/wizards/onshape_import_wizard.py +++ b/connector_onshape/wizards/onshape_import_wizard.py @@ -44,8 +44,7 @@ def action_import(self): if backend.state != "active": raise UserError(_("Backend must be active. Check credentials first.")) - if self.auto_create_products: - backend.write({"auto_create_products": True}) + backend.write({"auto_create_products": self.auto_create_products}) if self.import_type in ("documents", "products", "boms", "full"): backend.action_import_documents() From 618f158e2eef21f7fcd667fcff3cf3ddf47ac0cd Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Wed, 25 Feb 2026 17:22:02 -0700 Subject: [PATCH 4/5] fix: address Bugbot and CodeRabbit round 3 review findings Bugbot fixes: - importer: omit categ_id when no default category set to avoid ValidationError on auto-created products - importer: match_score returns 0-100 for progressbar widget - importer: exclude onshape_state from update_vals to preserve webhook-set lifecycle states on re-import - wizard: use with_delay() for async imports matching notification - webhook: reject requests when webhook_secret not configured CodeRabbit fixes: - webhook: use correct Onshape header X-onshape-webhook-signature-primary with Base64 encoding instead of hex - webhook: scope metadata change handler to single document instead of full backend resync - adapter: use companyId field instead of invalid filter expression for team-scoped webhook registration - mapper: add Weight to KNOWN_PROPERTIES to prevent round-trip data loss to custom_props - importer: batch importer accepts optional documents parameter for document-scoped imports - backend: add action_import_products_for_document() method Co-Authored-By: Claude Opus 4.6 --- connector_onshape/components/adapter.py | 2 +- connector_onshape/components/importer.py | 26 ++++++----- connector_onshape/components/mapper.py | 1 + connector_onshape/controllers/webhook.py | 46 ++++++++++--------- connector_onshape/models/onshape_backend.py | 8 ++++ .../wizards/onshape_import_wizard.py | 8 ++-- 6 files changed, 52 insertions(+), 39 deletions(-) diff --git a/connector_onshape/components/adapter.py b/connector_onshape/components/adapter.py index 52f30b8f0..bbe08f410 100644 --- a/connector_onshape/components/adapter.py +++ b/connector_onshape/components/adapter.py @@ -339,7 +339,7 @@ def register_webhook(self, url, events=None): "events": events, } if backend.team_id: - body["filter"] = json.dumps({"teamId": backend.team_id}) + body["companyId"] = backend.team_id return self._request("POST", "/api/v6/webhooks", json_body=body) def list_webhooks(self): diff --git a/connector_onshape/components/importer.py b/connector_onshape/components/importer.py index eea7a286b..fc85eb05d 100644 --- a/connector_onshape/components/importer.py +++ b/connector_onshape/components/importer.py @@ -163,10 +163,11 @@ class OnshapeProductBatchImporter(Component): _usage = "batch.importer" _apply_on = "onshape.product.product" - def run(self, backend, **kwargs): - documents = self.env["onshape.document"].search( - [("backend_id", "=", backend.id)] - ) + def run(self, backend, documents=None, **kwargs): + if documents is None: + documents = self.env["onshape.document"].search( + [("backend_id", "=", backend.id)] + ) adapter = self.component(usage="backend.adapter") total = 0 @@ -256,6 +257,7 @@ def run(self, backend, document, element, part_data): "backend_id", "external_id", "match_type", + "onshape_state", ) } update_vals["sync_date"] = fields.Datetime.now() @@ -326,14 +328,14 @@ def _match_or_create_product(self, backend, part_data, vals): # Auto-create product part_name = part_data.get("name", "Unknown Part") + create_vals = { + "name": part_name, + "type": "product", + } categ = backend.default_product_category_id - product = self.env["product.product"].create( - { - "name": part_name, - "type": "product", - "categ_id": categ.id if categ else False, - } - ) + if categ: + create_vals["categ_id"] = categ.id + product = self.env["product.product"].create(create_vals) vals["match_type"] = "auto_created" return product @@ -544,4 +546,4 @@ def _compute_match_score(self, bom_items, bom_lines): if total == 0: return 0.0 matched = len(bom_lines) - return round(matched / total, 3) + return round(matched / total * 100, 1) diff --git a/connector_onshape/components/mapper.py b/connector_onshape/components/mapper.py index 48b547327..baed10cac 100644 --- a/connector_onshape/components/mapper.py +++ b/connector_onshape/components/mapper.py @@ -24,6 +24,7 @@ "Revision", "Vendor", "Project", + "Weight", "Title 1", "Title 2", "Title 3", diff --git a/connector_onshape/controllers/webhook.py b/connector_onshape/controllers/webhook.py index 2efbb09be..59584667b 100644 --- a/connector_onshape/controllers/webhook.py +++ b/connector_onshape/controllers/webhook.py @@ -1,6 +1,7 @@ # Copyright 2024 Kencove Farm Fence Supplies # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import base64 import hashlib import hmac import json @@ -40,26 +41,25 @@ def webhook(self, backend_id, **kwargs): _logger.warning("Webhook received for unknown backend %s", backend_id) return {"status": "error", "message": "Unknown backend"} - # Validate HMAC signature + # Validate HMAC signature (required) raw_body = request.httprequest.get_data() if not backend.webhook_secret: _logger.warning( "Webhook secret not configured for backend %s. " - "Set webhook_secret to verify event authenticity.", + "Rejecting unauthenticated webhook.", backend_id, ) - if backend.webhook_secret: - signature = request.httprequest.headers.get( - "X-Onshape-Webhook-Signature", "" + return {"status": "error", "message": "Webhook secret not configured"} + + signature = request.httprequest.headers.get( + "X-onshape-webhook-signature-primary", "" + ) + if not self._validate_signature(raw_body, backend.webhook_secret, signature): + _logger.warning( + "Webhook signature validation failed for backend %s", + backend_id, ) - if not self._validate_signature( - raw_body, backend.webhook_secret, signature - ): - _logger.warning( - "Webhook signature validation failed for backend %s", - backend_id, - ) - return {"status": "error", "message": "Invalid signature"} + return {"status": "error", "message": "Invalid signature"} try: payload = json.loads(raw_body) @@ -83,11 +83,13 @@ def webhook(self, backend_id, **kwargs): return {"status": "ok", "message": "Event not handled"} def _validate_signature(self, raw_body, secret, signature): - expected = hmac.new( - secret.encode("utf-8"), - raw_body, - digestmod=hashlib.sha256, - ).hexdigest() + expected = base64.b64encode( + hmac.new( + secret.encode("utf-8"), + raw_body, + digestmod=hashlib.sha256, + ).digest() + ).decode("ascii") return hmac.compare_digest(expected, signature) def _get_event_handler(self, event): @@ -102,8 +104,8 @@ def _get_event_handler(self, event): def _handle_metadata_change(self, backend, payload): """Re-import part metadata when it changes in Onshape. - Queues a re-import (not export) so we pull fresh data from - the Onshape API rather than overwriting what was just changed. + Queues a document-scoped re-import (not full backend resync) + so we pull fresh data from the Onshape API. """ doc_id = payload.get("documentId", "") if not doc_id: @@ -124,11 +126,11 @@ def _handle_metadata_change(self, backend, payload): _logger.debug("Webhook metadata change for unknown doc %s", doc_id) return - # Queue re-import of products for this document + # Queue document-scoped product re-import (not full backend resync) backend.with_delay( priority=10, description=f"Re-import products for doc {document.name}", - ).action_import_products() + ).action_import_products_for_document(document) def _handle_workflow_transition(self, backend, payload): """Update lifecycle state when workflow transitions.""" diff --git a/connector_onshape/models/onshape_backend.py b/connector_onshape/models/onshape_backend.py index 431e002be..34d8c33b8 100644 --- a/connector_onshape/models/onshape_backend.py +++ b/connector_onshape/models/onshape_backend.py @@ -121,6 +121,14 @@ def action_import_products(self): importer = work.component(usage="batch.importer") importer.run(backend=self) + def action_import_products_for_document(self, document): + """Import products for a single document (webhook-triggered).""" + self.ensure_one() + self._check_active() + with self.work_on("onshape.product.product") as work: + importer = work.component(usage="batch.importer") + importer.run(backend=self, documents=document) + def action_import_boms(self): self.ensure_one() self._check_active() diff --git a/connector_onshape/wizards/onshape_import_wizard.py b/connector_onshape/wizards/onshape_import_wizard.py index e0d88655e..7f7341dd4 100644 --- a/connector_onshape/wizards/onshape_import_wizard.py +++ b/connector_onshape/wizards/onshape_import_wizard.py @@ -47,16 +47,16 @@ def action_import(self): backend.write({"auto_create_products": self.auto_create_products}) if self.import_type in ("documents", "products", "boms", "full"): - backend.action_import_documents() + backend.with_delay().action_import_documents() if self.import_type in ("products", "boms", "full"): - backend.action_import_products() + backend.with_delay().action_import_products() if self.import_type in ("boms", "full"): - backend.action_import_boms() + backend.with_delay().action_import_boms() if self.import_type == "full": - backend.action_export_part_numbers() + backend.with_delay().action_export_part_numbers() return { "type": "ir.actions.client", From 49bb23927542505aa9a7c404ce6c77283e06fd31 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Wed, 25 Feb 2026 22:46:19 -0700 Subject: [PATCH 5/5] fix: load wizard views before backend views in manifest The menuitem in onshape_backend_views.xml references action_onshape_import_wizard defined in the wizard views XML. Move wizard views earlier in the data list to fix ParseError on module install. Co-Authored-By: Claude Opus 4.6 --- connector_onshape/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/connector_onshape/__manifest__.py b/connector_onshape/__manifest__.py index a99f4d282..7f1823b0a 100644 --- a/connector_onshape/__manifest__.py +++ b/connector_onshape/__manifest__.py @@ -25,12 +25,12 @@ "data/queue_job_channel_data.xml", "data/queue_job_function_data.xml", "data/ir_cron_data.xml", + "wizards/onshape_import_wizard_views.xml", "views/onshape_backend_views.xml", "views/onshape_document_views.xml", "views/onshape_product_views.xml", "views/product_template_views.xml", "views/mrp_bom_views.xml", - "wizards/onshape_import_wizard_views.xml", ], "installable": True, "application": True,