diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3579613c0..276913959 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -141,7 +141,7 @@ repos: - --settings=. exclude: /__init__\.py$ - repo: https://github.com/acsone/setuptools-odoo - rev: 3.1.8 + rev: 3.3.2 hooks: - id: setuptools-odoo-make-default - id: setuptools-odoo-get-requirements diff --git a/connector_onshape/README.rst b/connector_onshape/README.rst new file mode 100644 index 000000000..131d33d14 --- /dev/null +++ b/connector_onshape/README.rst @@ -0,0 +1,302 @@ +================= +Onshape Connector +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:cad63694c119ae4a13923eac90ec6479b6ad3a428107dae056d5042bc988d120 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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 Company ID (Enterprise/Professional only):** + +1. Go to https://cad.onshape.com +2. Click **Company** in the left sidebar +3. The URL will show the company ID: + ``https://cad.onshape.com/company/`` +4. Leave this field empty on Education/Student/Free plans. + +**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. ``My 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 + - **Onshape Company ID**: From step above (leave empty for EDU/Free + plans) + +4. For **HMAC** mode, enter the **API Access Key** and **API Secret + Key**. + +5. For **OAuth2** mode: + + a. Enter the **OAuth2 Client ID** and **Client Secret** from the + Onshape Developer Portal. Make sure you copy the **complete** secret + including any trailing ``=`` padding characters (base64 encoding). + + b. Copy the **OAuth2 Redirect URI** shown on the form (click the + clipboard icon) and register it in your Onshape app's redirect URLs. + + c. Click **Authorize with Onshape** — you will be redirected to + Onshape to approve access. After approval, Onshape redirects back to + Odoo and the token is stored automatically. + + d. The **OAuth2 Authorized** checkbox confirms the token was + obtained. + +6. Click **Check Credentials** — should show a green success + notification. + +7. 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. Click **Generate Secret** on the backend form to create a webhook + secret. + +2. Click **Register Webhook** to register webhooks with Onshape. + + - **Enterprise/Professional** (Company ID set): registers a single + company-wide webhook. + - **Education/Free** (no Company ID): registers one webhook per + document. New documents imported later get webhooks automatically. + +3. The **Webhook URL** field (read-only) shows the endpoint URL. Ensure + your Odoo instance is reachable from the internet at that address + (Onshape must be able to POST to it). + +Clicking **Register Webhook** again cleans up stale duplicates and only +registers for documents that are missing a webhook. The webhook ID is +tracked on each document record. + +Events handled: + +- ``onshape.model.lifecycle.metadata`` — re-imports part metadata +- ``onshape.model.lifecycle.createversion`` — syncs document on version + creation +- ``onshape.workflow.transition`` — updates lifecycle state (Enterprise + only) +- ``onshape.revision.created`` — marks parts as released (Enterprise + only) +- ``webhook.unregister`` — clears tracked webhook ID when Onshape + expires it + +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 Onshape account. 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 +------------ + +- Don Kendall dkendall@kencove.com + +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..5a73f4fcf --- /dev/null +++ b/connector_onshape/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import components, controllers, models, wizards diff --git a/connector_onshape/__manifest__.py b/connector_onshape/__manifest__.py new file mode 100644 index 000000000..400af514f --- /dev/null +++ b/connector_onshape/__manifest__.py @@ -0,0 +1,38 @@ +# 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", + "development_status": "Beta", + "depends": [ + "connector", + "component", + "component_event", + "queue_job", + "product", + "mrp", + ], + "external_dependencies": { + "python": ["requests"], + }, + "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", + "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", + ], + "installable": True, + "application": False, +} diff --git a/connector_onshape/components/__init__.py b/connector_onshape/components/__init__.py new file mode 100644 index 000000000..9307537ae --- /dev/null +++ b/connector_onshape/components/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import core, adapter, binder, exporter, importer, listener, mapper diff --git a/connector_onshape/components/adapter.py b/connector_onshape/components/adapter.py new file mode 100644 index 000000000..1a7409c5e --- /dev/null +++ b/connector_onshape/components/adapter.py @@ -0,0 +1,443 @@ +# 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 secrets +import string +import threading +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 + +# Per-backend lock for OAuth2 token refresh. Prevents concurrent threads +# from refreshing at the same time (second refresh invalidates the first). +_OAUTH2_REFRESH_LOCKS = {} +_OAUTH2_LOCKS_LOCK = threading.Lock() + + +class OnshapeAdapter(Component): + """API adapter for Onshape REST API. + + Supports HMAC authentication (ported from onshape_utils.py) + and OAuth2 bearer tokens. + + 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") + 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 = ( + 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() + 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}", + "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 _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 _try_refresh_oauth2(self, resp, attempt): + """Refresh OAuth2 token on 401 (first attempt only). + + Uses a per-backend lock so that when multiple threads hit 401 + simultaneously, only one performs the refresh and the others + reuse the new token. + """ + backend = self._get_backend() + if resp.status_code != 401 or backend.auth_mode != "oauth2" or attempt != 0: + return False + + # Get or create a lock for this backend + backend_id = backend.id + with _OAUTH2_LOCKS_LOCK: + if backend_id not in _OAUTH2_REFRESH_LOCKS: + _OAUTH2_REFRESH_LOCKS[backend_id] = threading.Lock() + lock = _OAUTH2_REFRESH_LOCKS[backend_id] + + # Read the token that produced the 401 so we can detect if + # another thread already refreshed while we waited for the lock. + stale_token = (backend.oauth2_token or "")[:50] + + with lock: + # Re-read from DB — another thread may have refreshed already + backend.invalidate_recordset(["oauth2_token"]) + current_token = (backend.oauth2_token or "")[:50] + if current_token != stale_token and current_token: + _logger.info( + "OAuth2 token already refreshed by another thread, reusing" + ) + return True + + new_token = backend._oauth2_refresh_token() + if new_token.get("access_token"): + _logger.info("OAuth2 token refreshed, retrying request") + return True + return False + + def _check_retryable(self, resp, attempt): + """Return wait seconds if request should be retried, else None.""" + if resp.status_code == 402: + backend = self._get_backend() + if backend.auth_mode == "oauth2": + _logger.error( + "Onshape API quota exhausted (402) with OAuth2. " + "Publish the app on the Onshape App Store to bypass " + "quota, or contact api-support@onshape.com." + ) + raise OnshapeQuotaError( + "Onshape API quota exhausted. Your OAuth2 app must be " + "publicly published on the Onshape App Store to bypass " + "the annual limit. Contact api-support@onshape.com or " + "onshape-developer-relations@ptc.com." + ) + _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. + + 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 + self._log_rate_limit(resp) + + # Refresh OAuth2 token on 401 and retry + if self._try_refresh_oauth2(resp, attempt): + continue + + wait = self._check_retryable(resp, attempt) + if wait is not None: + time.sleep(wait) + continue + + if raw: + return resp + if resp.status_code >= 400: + _logger.error( + "Onshape API %s %s → %s: %s", + method, + path, + resp.status_code, + resp.text[:500], + ) + resp.raise_for_status() + if resp.status_code == 204: + return {} + return resp.json() + + except requests.exceptions.HTTPError: + # Client/server HTTP errors — don't retry, let caller handle + raise + except requests.exceptions.RequestException: + # Connection errors, timeouts — retry with backoff + 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 # company + 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_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", + 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).decode("utf-8") + return None + + # --- Webhooks --- + + # Events that can be registered per-document (no company required) + DOCUMENT_EVENTS = [ + "onshape.model.lifecycle.metadata", + "onshape.model.lifecycle.createversion", + ] + # Events that require a companyId (Enterprise/Professional plans only) + COMPANY_EVENTS = [ + "onshape.workflow.transition", + "onshape.revision.created", + ] + + def register_webhook(self, url, events=None, document_id=None): + backend = self._get_backend() + if events is None: + if backend.onshape_company_id: + events = self.DOCUMENT_EVENTS + self.COMPANY_EVENTS + else: + events = self.DOCUMENT_EVENTS + body = { + "url": url, + "events": events, + "options": {"collapseEvents": True}, + } + if backend.onshape_company_id: + body["companyId"] = backend.onshape_company_id + elif document_id: + body["documentId"] = document_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): + try: + return self._request("DELETE", f"/api/v6/webhooks/{webhook_id}") + except requests.exceptions.HTTPError as e: + if e.response is not None and e.response.status_code == 404: + return {} # Already deleted / expired + raise + + +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..6c5a2c6ff --- /dev/null +++ b/connector_onshape/components/binder.py @@ -0,0 +1,71 @@ +# 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): + if not external_id: + raise ValueError("external_id must be a non-empty string") + 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]} + raise ValueError("Cannot parse compound ID: %r" % 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..824d28d84 --- /dev/null +++ b/connector_onshape/components/exporter.py @@ -0,0 +1,101 @@ +# 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) + + items = meta.get("items", []) + if not items: + _logger.info( + "No metadata items for binding %s — skipping export", binding.id + ) + 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 + + # 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 in export_values and export_values[prop_name]: + properties_update.append( + { + "propertyId": prop["propertyId"], + "value": str(export_values[prop_name]), + } + ) + + 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, + export_values.get("Part Number", ""), + ) diff --git a/connector_onshape/components/importer.py b/connector_onshape/components/importer.py new file mode 100644 index 000000000..0a60b4a10 --- /dev/null +++ b/connector_onshape/components/importer.py @@ -0,0 +1,574 @@ +# 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 datetime import datetime + +from odoo import fields + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +def _parse_iso_datetime(value): + """Convert ISO 8601 datetime string to Odoo-compatible naive format. + + Handles the ``Z`` suffix that ``datetime.fromisoformat`` only supports + from Python 3.11+. + """ + if not value: + return False + try: + # Replace Z suffix for Python < 3.11 compatibility + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, TypeError): + return False + + +class OnshapeDocumentBatchImporter(Component): + """Batch importer for Onshape documents. + + Lists all documents from Onshape 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 = 20 + total_queued = 0 + + while True: + result = adapter.search_documents( + owner_id=backend.onshape_company_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 = _parse_iso_datetime(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": _parse_iso_datetime(doc_data.get("createdAt")), + "modified_at": _parse_iso_datetime(doc_data.get("modifiedAt")), + } + document = self.env["onshape.document"].create(vals) + + # Import elements + if workspace_id: + self._import_elements(backend, document, workspace_id) + + # Auto-register webhook for per-document mode (Free/EDU plans) + if ( + not backend.onshape_company_id + and backend.state == "active" + and backend.webhook_secret + ): + backend._register_document_webhook(document) + + 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, 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 + + 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") + adapter = self.component(usage="backend.adapter") + + part_id = part_data.get("partId", "") + external_id = binder.make_compound_id( + document.onshape_document_id, + element.onshape_element_id, + 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 + 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", + "onshape_state", + ) + } + 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 _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") + 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") + create_vals = { + "name": part_name, + "type": "product", + } + categ = backend.default_product_category_id + if categ: + create_vals["categ_id"] = categ.id + product = self.env["product.product"].create(create_vals) + 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( + [("name", "=", 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 * 100, 1) 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..37e9df7a3 --- /dev/null +++ b/connector_onshape/components/mapper.py @@ -0,0 +1,248 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +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}$") + +# Standard Onshape metadata property names we recognize +KNOWN_PROPERTIES = { + "Part Number", + "Name", + "Description", + "Material", + "Appearance", + "Revision", + "Vendor", + "Project", + "Weight", + "Title 1", + "Title 2", + "Title 3", +} + + +class OnshapeProductImportMapper(Component): + """Map Onshape part data to onshape.product.product fields. + + 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. + """ + + _name = "onshape.product.import.mapper" + _inherit = "onshape.base" + _usage = "import.mapper" + _apply_on = "onshape.product.product" + + # 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", + } + + 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 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") 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: + vals["onshape_volume"] = total_volume + if total_area: + vals["onshape_surface_area"] = total_area + + def _search_by_code(self, code): + """Search for a product by exact default_code.""" + if not code: + return self.env["product.product"] + return self.env["product.product"].search( + [("default_code", "=", code)], limit=1 + ) + + 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). + """ + 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) + + result = self._match_by_name(base_no_ver, base_name) + if not result[0]: + result = self._match_by_part_number(part_number) + if not result[0]: + result = self._match_by_mcmaster(base_no_ver) + if not result[0]: + result = self._match_case_insensitive(base_no_ver) + return result + + def _match_by_name(self, base_no_ver, base_name): + """Strategy 1: Exact filename match against default_code.""" + if not base_no_ver: + return None, None + product = self._search_by_code(base_no_ver) + if product: + return product, "exact_filename" + product = self._search_by_code(base_name) + if product: + return product, "exact_filename_versioned" + return None, None + + def _match_by_part_number(self, part_number): + """Strategy 2: Part number from Onshape metadata.""" + if not part_number: + return None, None + product = self._search_by_code(part_number) + if product: + return product, "part_name" + pn_prefix = part_number.split("_")[0].strip() + if pn_prefix and pn_prefix != part_number: + product = self._search_by_code(pn_prefix) + if product: + return product, "part_name_prefix" + return None, None + + def _match_by_mcmaster(self, base_no_ver): + """Strategy 3: McMaster catalog number extraction.""" + if not base_no_ver: + return None, None + mcmaster_match = MCMASTER_RE.search(base_no_ver) + if mcmaster_match: + product = self._search_by_code(mcmaster_match.group(1)) + if product: + return product, "mcmaster_catalog" + return None, None + + def _match_case_insensitive(self, base_no_ver): + """Strategy 4: Case-insensitive match via casing variants.""" + if not base_no_ver: + return None, None + for variant in (base_no_ver.upper(), base_no_ver.lower()): + if variant == base_no_ver: + continue # Already tried exact in strategy 1 + product = self._search_by_code(variant) + if product: + return product, "case_insensitive" + return None, None + + +class OnshapeProductExportMapper(Component): + """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" + _usage = "export.mapper" + _apply_on = "onshape.product.product" + + def map_record(self, binding): + 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/controllers/__init__.py b/connector_onshape/controllers/__init__.py new file mode 100644 index 000000000..823cccce2 --- /dev/null +++ b/connector_onshape/controllers/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import oauth +from . import webhook diff --git a/connector_onshape/controllers/oauth.py b/connector_onshape/controllers/oauth.py new file mode 100644 index 000000000..4dfc6af4d --- /dev/null +++ b/connector_onshape/controllers/oauth.py @@ -0,0 +1,96 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class OnshapeOAuthController(http.Controller): + """Handle the OAuth2 authorization code callback from Onshape. + + Flow: + 1. User clicks "Authorize" on the Onshape backend form. + 2. Odoo redirects to https://oauth.onshape.com/oauth/authorize + 3. User approves on Onshape. + 4. Onshape redirects here with ?code=... + 5. We exchange the code for access + refresh tokens. + + Note: Onshape does not always return the ``state`` parameter in the + callback, so we look up the pending backend by its stored CSRF token + as a fallback. + """ + + def _resolve_backend(self, state): + """Find the backend that initiated the OAuth flow. + + The ``state`` parameter is the plain CSRF token stored on the backend. + If Onshape omits state, fall back to searching for a pending token. + """ + Backend = request.env["onshape.backend"].sudo() + + if state: + backend = Backend.search([("oauth2_csrf_token", "=", state)], limit=1) + if backend: + return backend, state + + # Onshape may omit state — find the backend with a pending CSRF token + backend = Backend.search([("oauth2_csrf_token", "!=", False)], limit=1) + if backend: + return backend, backend.oauth2_csrf_token + return None, None + + @http.route( + "/connector_onshape/oauth/callback", + type="http", + auth="user", + methods=["GET"], + csrf=False, + ) + def oauth_callback(self, **kwargs): + error = kwargs.get("error") + if error: + error_desc = kwargs.get("error_description", error) + _logger.error("OAuth2 error from Onshape: %s", error_desc) + return request.redirect("/web") + + code = kwargs.get("code") + if not code: + _logger.error("OAuth2 callback missing code") + return request.redirect("/web") + + state = kwargs.get("state") + backend, csrf_token = self._resolve_backend(state) + + if not backend: + _logger.error("OAuth2 callback: no pending backend found") + return request.redirect("/web") + + # Verify CSRF token + if not backend.oauth2_csrf_token or backend.oauth2_csrf_token != csrf_token: + _logger.error("OAuth2 CSRF token mismatch for backend %s", backend.id) + return request.redirect("/web") + + # Exchange code for token — include action so Odoo renders the menu + action_id = request.env.ref( + "connector_onshape.action_onshape_backend", raise_if_not_found=False + ) + action_param = "&action=%d" % action_id.id if action_id else "" + form_url = "/web#id=%d&model=onshape.backend&view_type=form%s" % ( + backend.id, + action_param, + ) + try: + backend._oauth2_exchange_code(code) + _logger.info("OAuth2 authorization successful for backend %s", backend.id) + except Exception: + _logger.exception("OAuth2 token exchange failed for backend %s", backend.id) + # Clear the CSRF token so the user can retry + backend.write({"oauth2_csrf_token": False}) + return request.redirect(form_url) + + # Redirect back to the backend form + return request.redirect(form_url) diff --git a/connector_onshape/controllers/webhook.py b/connector_onshape/controllers/webhook.py new file mode 100644 index 000000000..9c8bebf67 --- /dev/null +++ b/connector_onshape/controllers/webhook.py @@ -0,0 +1,329 @@ +# 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 threading +import time +from datetime import datetime + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + +# In-memory dedup cache: {dedup_key: timestamp}. +# Prevents redundant processing when Onshape fires the same event +# multiple times within a short window (common with per-document webhooks). +# Protected by _DEDUP_LOCK because concurrent threads can race on +# the read-check-write sequence. +_RECENT_EVENTS = {} +_DEDUP_LOCK = threading.Lock() +_DEDUP_WINDOW = 5 # seconds + + +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 when available. + # Enterprise plans: Onshape signs payloads with keys configured in + # the company admin panel. The webhook_secret field should match + # the primary or secondary key from Onshape's settings. + # EDU/Free plans: Onshape may not send signature headers. + raw_body = request.httprequest.get_data() + signature = request.httprequest.headers.get( + "X-onshape-webhook-signature-primary", "" + ) + timestamp = request.httprequest.headers.get("X-onshape-webhook-timestamp", "") + if signature: + if not backend.webhook_secret: + _logger.warning( + "Webhook has signature but no secret configured " + "for backend %s. Rejecting.", + backend_id, + ) + return {"status": "error", "message": "Webhook secret not configured"} + if not self._validate_signature( + raw_body, backend.webhook_secret, signature, timestamp + ): + _logger.warning( + "Webhook signature validation failed for backend %s", + backend_id, + ) + return {"status": "error", "message": "Invalid signature"} + else: + _logger.debug( + "No signature header on webhook for backend %s — " + "skipping HMAC validation (EDU/Free plan).", + backend_id, + ) + + 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, + ) + + # Deduplicate rapid-fire identical events (webhookId excluded). + doc_id = payload.get("documentId", "") + dedup_key = ( + backend_id, + event, + doc_id, + payload.get("versionName", ""), + payload.get("transitionName", ""), + payload.get("elementId", ""), + payload.get("partId", ""), + ) + now = time.monotonic() + with _DEDUP_LOCK: + last_seen = _RECENT_EVENTS.get(dedup_key, 0) + if now - last_seen < _DEDUP_WINDOW: + _logger.info( + "Skipping duplicate webhook: event=%s doc=%s (%.1fs ago)", + event, + doc_id, + now - last_seen, + ) + return {"status": "ok", "message": "Duplicate suppressed"} + _RECENT_EVENTS[dedup_key] = now + # Prune stale entries to avoid unbounded growth + if len(_RECENT_EVENTS) > 500: + cutoff = now - _DEDUP_WINDOW + stale = [k for k, v in _RECENT_EVENTS.items() if v < cutoff] + for k in stale: + del _RECENT_EVENTS[k] + + # 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, timestamp=""): + """Validate Onshape webhook HMAC-SHA256 signature. + + Onshape signs ``timestamp + "." + raw_body`` and sends the result + as a Base64-encoded HMAC-SHA256 digest. + """ + message = timestamp.encode("utf-8") + b"." + raw_body if timestamp else raw_body + expected = base64.b64encode( + hmac.new( + secret.encode("utf-8"), + message, + digestmod=hashlib.sha256, + ).digest() + ).decode("ascii") + 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), + "webhook.unregister": self._handle_webhook_unregister, + } + return handlers.get(event) + + def _handle_metadata_change(self, backend, payload): + """Re-import part metadata when it changes in Onshape. + + 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: + 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 + + # Sync document name/timestamps + self._sync_document(backend, doc_id) + + # 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_for_document(document) + + 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 = self._find_bindings(backend, payload) + 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.""" + 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): + """Sync document on version creation.""" + doc_id = payload.get("documentId", "") + version_name = payload.get("versionName", "") + _logger.info( + "Onshape version created: doc=%s version=%s", + doc_id, + version_name, + ) + self._sync_document(backend, doc_id) + + def _handle_webhook_unregister(self, backend, payload): + """Clear webhook tracking when Onshape expires/unregisters a webhook. + + Onshape sends this event when a transient webhook expires or is + manually deleted. We look up the document by stored webhook ID + and clear the field so the next "Register Webhook" run re-creates it. + """ + webhook_id = payload.get("webhookId", "") + if not webhook_id: + return + document = ( + request.env["onshape.document"] + .sudo() + .search( + [ + ("backend_id", "=", backend.id), + ("onshape_webhook_id", "=", webhook_id), + ], + limit=1, + ) + ) + if document: + document.write({"onshape_webhook_id": False}) + _logger.info( + "Webhook %s expired/unregistered — cleared from document %s (%s)", + webhook_id, + document.name, + document.onshape_document_id, + ) + else: + _logger.debug( + "webhook.unregister for unknown webhook ID %s on backend %s", + webhook_id, + backend.id, + ) + + def _sync_document(self, backend, doc_id): + """Re-fetch document data from Onshape and update local record.""" + 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: + return + try: + with backend.work_on("onshape.backend") as work: + adapter = work.component(usage="backend.adapter") + doc_data = adapter.read_document(doc_id) + vals = {} + name = doc_data.get("name") + if name and name != document.name: + vals["name"] = name + modified = doc_data.get("modifiedAt") + if modified: + try: + dt = datetime.fromisoformat(modified.replace("Z", "+00:00")) + vals["modified_at"] = dt.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, TypeError): + _logger.debug("Could not parse modifiedAt %r", modified) + if vals: + document.write(vals) + _logger.info("Document %s synced: %s", doc_id, vals) + except Exception: + _logger.exception("Failed to sync document %s", doc_id) diff --git a/connector_onshape/data/ir_cron_data.xml b/connector_onshape/data/ir_cron_data.xml new file mode 100644 index 000000000..1f6bb76f9 --- /dev/null +++ b/connector_onshape/data/ir_cron_data.xml @@ -0,0 +1,43 @@ + + + + + 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..ac205681f --- /dev/null +++ b/connector_onshape/data/queue_job_function_data.xml @@ -0,0 +1,32 @@ + + + + + + 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..2210958f3 --- /dev/null +++ b/connector_onshape/models/__init__.py @@ -0,0 +1,12 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +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..acbee323a --- /dev/null +++ b/connector_onshape/models/onshape_backend.py @@ -0,0 +1,539 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging +import secrets +from urllib.parse import quote, urlencode + +import requests as req_lib + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +ONSHAPE_OAUTH_AUTHORIZE = "https://oauth.onshape.com/oauth/authorize" +ONSHAPE_OAUTH_TOKEN = "https://oauth.onshape.com/oauth/token" + + +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") + oauth2_csrf_token = fields.Char(groups="base.group_system") + oauth2_authorized = fields.Boolean( + compute="_compute_oauth2_authorized", + string="OAuth2 Authorized", + ) + oauth2_redirect_uri = fields.Char( + compute="_compute_oauth2_redirect_uri", + string="OAuth2 Redirect URI", + help="Register this URL in your Onshape app's redirect URLs.", + ) + onshape_company_id = fields.Char( + string="Onshape Company ID", + help="Enterprise/Professional plan company ID. " + "Required for company-wide webhooks. " + "Leave empty on Education/Student plans.", + ) + webhook_secret = fields.Char(groups="base.group_system") + webhook_url = fields.Char( + compute="_compute_webhook_url", + string="Webhook URL", + ) + 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") + + def _compute_oauth2_authorized(self): + for rec in self: + token_str = rec.oauth2_token or "{}" + try: + token_data = json.loads(token_str) + except (ValueError, TypeError): + token_data = {} + rec.oauth2_authorized = bool(token_data.get("access_token")) + + def _compute_oauth2_redirect_uri(self): + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + uri = "%s/connector_onshape/oauth/callback" % base_url + for rec in self: + rec.oauth2_redirect_uri = uri + + def _compute_webhook_url(self): + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + for rec in self: + if isinstance(rec.id, int): + rec.webhook_url = "%s/connector_onshape/webhook/%d" % ( + base_url, + rec.id, + ) + else: + rec.webhook_url = False + + @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") + + # --- OAuth2 flow --- + + def action_oauth2_authorize(self): + """Redirect to Onshape OAuth2 authorization page.""" + self.ensure_one() + if not self.oauth2_client_id: + raise UserError(_("Please set the OAuth2 Client ID and Secret first.")) + csrf_token = secrets.token_urlsafe(32) + self.write({"oauth2_csrf_token": csrf_token}) + # Onshape rejects JSON in the state parameter — use a plain opaque token. + # The callback resolves the backend by matching oauth2_csrf_token. + params = { + "response_type": "code", + "client_id": self.oauth2_client_id, + "redirect_uri": self.oauth2_redirect_uri, + "state": csrf_token, + } + auth_url = "%s?%s" % ( + ONSHAPE_OAUTH_AUTHORIZE, + urlencode(params, quote_via=quote), + ) + return { + "type": "ir.actions.act_url", + "url": auth_url, + "target": "self", + } + + def _oauth2_exchange_code(self, code): + """Exchange authorization code for access + refresh tokens.""" + self.ensure_one() + redirect_uri = self.oauth2_redirect_uri + client_id = (self.oauth2_client_id or "").strip() + client_secret = (self.oauth2_client_secret or "").strip() + # Onshape requires credentials in the POST body (not Basic Auth). + # Use a raw string body so the trailing '=' in client_id/secret + # is sent literally (not URL-encoded as %3D). + body = ( + "grant_type=authorization_code" + "&code=%s" + "&redirect_uri=%s" + "&client_id=%s" + "&client_secret=%s" + ) % ( + code, + quote(redirect_uri, safe=""), + client_id, + client_secret, + ) + resp = req_lib.post( + ONSHAPE_OAUTH_TOKEN, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30, + ) + if resp.status_code != 200: + _logger.error( + "OAuth2 token exchange failed for backend %s: %s", + self.id, + resp.text[:200], + ) + resp.raise_for_status() + token_data = resp.json() + self.write( + { + "oauth2_token": json.dumps(token_data), + "oauth2_csrf_token": False, + } + ) + _logger.info("OAuth2 token obtained for backend %s", self.id) + + def _oauth2_refresh_token(self): + """Refresh an expired OAuth2 access token.""" + self.ensure_one() + try: + token_data = json.loads(self.oauth2_token or "{}") + except (ValueError, TypeError): + return {} + refresh_token = token_data.get("refresh_token") + if not refresh_token: + _logger.error("No refresh token for backend %s — re-authorize.", self.id) + return {} + # Raw string body — Onshape may not decode %3D in client_id + body = ( + "grant_type=refresh_token" + "&refresh_token=%s" + "&client_id=%s" + "&client_secret=%s" + ) % ( + refresh_token, + (self.oauth2_client_id or "").strip(), + (self.oauth2_client_secret or "").strip(), + ) + resp = req_lib.post( + ONSHAPE_OAUTH_TOKEN, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30, + ) + if resp.status_code != 200: + _logger.error( + "OAuth2 refresh failed for backend %s: %s", + self.id, + resp.text[:200], + ) + return {} + new_token_data = resp.json() + self.write({"oauth2_token": json.dumps(new_token_data)}) + _logger.info("OAuth2 token refreshed for backend %s", self.id) + return new_token_data + + # --- Credential check --- + + def action_check_credentials(self): + self.ensure_one() + if self.auth_mode == "oauth2": + # Use sudo() to read the group-restricted oauth2_token field + sudo_rec = self.sudo() + token_str = sudo_rec.oauth2_token or "" + csrf_pending = bool(sudo_rec.oauth2_csrf_token) + try: + token_data = json.loads(token_str) if token_str else {} + except (ValueError, TypeError): + token_data = {} + if not token_data.get("access_token"): + if csrf_pending: + raise UserError( + _( + "OAuth2 authorization is still pending. " + "Please complete the authorization in the Onshape " + "window that was opened, then try again." + ) + ) + if token_str and not token_data.get("access_token"): + raise UserError( + _( + "OAuth2 token was saved but does not contain an " + "access_token. The token exchange may have failed. " + "Please click 'Authorize with Onshape' to retry. " + "Check the server logs for details." + ) + ) + raise UserError( + _( + "No OAuth2 token found. Please click " + "'Authorize with Onshape' to start the " + "authorization flow, approve access on Onshape, " + "and wait to be redirected back." + ) + ) + adapter = self._get_adapter() + ok, message = adapter.check_credentials() + if not ok: + raise UserError(_("Credential check failed: %s") % message) + self.write({"state": "checked"}) + + 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_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() + 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 action_generate_webhook_secret(self): + """Generate a random webhook secret.""" + self.ensure_one() + self.write({"webhook_secret": secrets.token_hex(32)}) + + def _cleanup_webhooks(self): + """Delete stale/duplicate webhooks for this backend's URL. + + Compares webhook IDs from Onshape against tracked IDs stored on + documents. Only deletes untracked (stale) webhooks; leaves + tracked ones in place so we don't needlessly re-register. + """ + adapter = self._get_adapter() + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + webhook_url = "%s/connector_onshape/webhook/%d" % (base_url, self.id) + result = adapter.list_webhooks() + items = result.get("items", []) if isinstance(result, dict) else [] + + # Collect tracked webhook IDs from documents + tracked_ids = set( + self.document_ids.filtered("onshape_webhook_id").mapped( + "onshape_webhook_id" + ) + ) + + deleted = 0 + for hook in items: + if hook.get("url") != webhook_url: + continue + hook_id = hook.get("id", "") + if hook_id in tracked_ids: + # This webhook is tracked by a document — keep it + continue + adapter.delete_webhook(hook_id) + deleted += 1 + + if deleted: + _logger.info( + "Cleaned up %d stale webhook(s) for backend %s", + deleted, + self.id, + ) + return deleted + + def _register_document_webhook(self, document): + """Register a webhook for a single document and store the ID. + + Returns True if successful, False otherwise. + """ + self.ensure_one() + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + webhook_url = "%s/connector_onshape/webhook/%d" % (base_url, self.id) + adapter = self._get_adapter() + try: + result = adapter.register_webhook( + webhook_url, document_id=document.onshape_document_id + ) + webhook_id = result.get("id", "") if isinstance(result, dict) else "" + if webhook_id: + document.write({"onshape_webhook_id": webhook_id}) + _logger.info( + "Registered webhook %s for document %s (%s)", + webhook_id, + document.name, + document.onshape_document_id, + ) + return True + _logger.warning( + "Webhook registration returned no ID for document %s", + document.onshape_document_id, + ) + except Exception: + _logger.exception( + "Failed to register webhook for document %s (%s)", + document.name, + document.onshape_document_id, + ) + return False + + def action_register_webhook(self): + self.ensure_one() + self._check_active() + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + webhook_url = "%s/connector_onshape/webhook/%d" % (base_url, self.id) + + if self.onshape_company_id: + # Enterprise path: company-wide webhook, clean up + register + self._cleanup_webhooks() + adapter = self._get_adapter() + adapter.register_webhook(webhook_url) + message = _("Company-wide webhook registered.") + else: + # Per-document path: clean up untracked webhooks, then register + # only for documents that don't already have a tracked webhook. + documents = self.document_ids + if not documents: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("No Documents"), + "message": _( + "No documents imported yet. Please click " + "'Import Documents' first, then register webhooks." + ), + "type": "warning", + "sticky": False, + }, + } + # Remove stale/duplicate webhooks (keeps tracked ones). + deleted = self._cleanup_webhooks() + # Only register for documents still missing a webhook. + need_webhook = documents.filtered(lambda d: not d.onshape_webhook_id) + if not need_webhook and not deleted: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Webhooks Up To Date"), + "message": _( + "All %d document(s) already have active " "webhooks." + ) + % len(documents), + "type": "info", + "sticky": False, + }, + } + registered = 0 + for doc in need_webhook: + if self._register_document_webhook(doc): + registered += 1 + message = _( + "Webhooks registered for %(registered)d document(s). " + "%(deleted)d stale webhook(s) removed." + ) % {"registered": registered, "deleted": deleted} + + _logger.info("Webhook registered for backend %s", self.id) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Webhook Registered"), + "message": message, + "type": "success", + "sticky": False, + }, + } + + 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.with_delay().action_import_documents() + + @api.model + def cron_import_products(self): + backends = self.search([("state", "=", "active")]) + for backend in backends: + backend.with_delay().action_import_products() + + @api.model + def cron_import_boms(self): + backends = self.search([("state", "=", "active")]) + for backend in backends: + backend.with_delay().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..b7c733dc9 --- /dev/null +++ b/connector_onshape/models/onshape_document.py @@ -0,0 +1,128 @@ +# 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, + ) + onshape_webhook_id = fields.Char( + string="Webhook ID", + index=True, + help="Onshape webhook ID registered for this document. " + "Empty means no active webhook.", + ) + 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 + + def name_get(self): + result = [] + for rec in self: + if rec.onshape_document_id: + name = f"[{rec.onshape_document_id[:8]}] {rec.name}" + else: + name = rec.name or "" + result.append((rec.id, name)) + return result + + +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..0af09546c --- /dev/null +++ b/connector_onshape/models/onshape_mrp_bom.py @@ -0,0 +1,78 @@ +# 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_document_id.onshape_default_workspace_id", + "onshape_element_id", + ) + def _compute_onshape_url(self): + for rec in self: + doc = rec.onshape_document_id + base = doc.backend_id.base_url if doc else False + did = doc.onshape_document_id if doc else False + workspace = doc.onshape_default_workspace_id if doc else False + elem = rec.onshape_element_id + if base and did and workspace and elem: + rec.onshape_url = f"{base}/documents/{did}/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..47d24196d --- /dev/null +++ b/connector_onshape/models/onshape_product_product.py @@ -0,0 +1,161 @@ +# 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_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"), + ("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_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 + 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/{doc.onshape_default_workspace_id}" + f"/e/{rec.onshape_element_id}" + ) + 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..d912b1c0c --- /dev/null +++ b/connector_onshape/models/product_template.py @@ -0,0 +1,26 @@ +# 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", + "product_variant_ids.onshape_bind_ids.onshape_document_id", + ) + 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.md b/connector_onshape/readme/CONFIGURE.md new file mode 100644 index 000000000..9a8a6f8bf --- /dev/null +++ b/connector_onshape/readme/CONFIGURE.md @@ -0,0 +1,123 @@ +## 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 Company ID (Enterprise/Professional only):** + +1. Go to https://cad.onshape.com +2. Click **Company** in the left sidebar +3. The URL will show the company ID: + `https://cad.onshape.com/company/` +4. Leave this field empty on Education/Student/Free plans. + +**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. `My 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 + - **Onshape Company ID**: From step above (leave empty for EDU/Free plans) + +4. For **HMAC** mode, enter the **API Access Key** and **API Secret Key**. + +5. For **OAuth2** mode: + + a. Enter the **OAuth2 Client ID** and **Client Secret** from the Onshape + Developer Portal. Make sure you copy the **complete** secret including + any trailing `=` padding characters (base64 encoding). + + b. Copy the **OAuth2 Redirect URI** shown on the form (click the clipboard + icon) and register it in your Onshape app's redirect URLs. + + c. Click **Authorize with Onshape** — you will be redirected to Onshape + to approve access. After approval, Onshape redirects back to Odoo + and the token is stored automatically. + + d. The **OAuth2 Authorized** checkbox confirms the token was obtained. + +6. Click **Check Credentials** — should show a green success notification. +7. 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. Click **Generate Secret** on the backend form to create a webhook secret. + +2. Click **Register Webhook** to register webhooks with Onshape. + + - **Enterprise/Professional** (Company ID set): registers a single + company-wide webhook. + - **Education/Free** (no Company ID): registers one webhook per + document. New documents imported later get webhooks automatically. + +3. The **Webhook URL** field (read-only) shows the endpoint URL. Ensure + your Odoo instance is reachable from the internet at that address + (Onshape must be able to POST to it). + +Clicking **Register Webhook** again cleans up stale duplicates and only +registers for documents that are missing a webhook. The webhook ID is +tracked on each document record. + +Events handled: + +- `onshape.model.lifecycle.metadata` — re-imports part metadata +- `onshape.model.lifecycle.createversion` — syncs document on version creation +- `onshape.workflow.transition` — updates lifecycle state (Enterprise only) +- `onshape.revision.created` — marks parts as released (Enterprise only) +- `webhook.unregister` — clears tracked webhook ID when Onshape expires it + +## 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.md b/connector_onshape/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..70a897807 --- /dev/null +++ b/connector_onshape/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Don Kendall diff --git a/connector_onshape/readme/DESCRIPTION.md b/connector_onshape/readme/DESCRIPTION.md new file mode 100644 index 000000000..49bf7c431 --- /dev/null +++ b/connector_onshape/readme/DESCRIPTION.md @@ -0,0 +1,20 @@ +This module provides bidirectional synchronization between Odoo and +[Onshape](https://www.onshape.com) 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.md b/connector_onshape/readme/USAGE.md new file mode 100644 index 000000000..7b5abb8fe --- /dev/null +++ b/connector_onshape/readme/USAGE.md @@ -0,0 +1,41 @@ +## Import Documents + +Click **Import Documents** on the backend form to fetch all Onshape +documents from your Onshape account. 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..651e289cb --- /dev/null +++ b/connector_onshape/security/onshape_security.xml @@ -0,0 +1,22 @@ + + + + + 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 000000000..32296bb99 Binary files /dev/null and b/connector_onshape/static/description/icon.png differ diff --git a/connector_onshape/static/description/icon.svg b/connector_onshape/static/description/icon.svg new file mode 100644 index 000000000..1d92ae25e --- /dev/null +++ b/connector_onshape/static/description/icon.svg @@ -0,0 +1,5 @@ + + + OS + diff --git a/connector_onshape/static/description/index.html b/connector_onshape/static/description/index.html new file mode 100644 index 000000000..f0fb81614 --- /dev/null +++ b/connector_onshape/static/description/index.html @@ -0,0 +1,649 @@ + + + + + +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 Company ID (Enterprise/Professional only):

+
    +
  1. Go to https://cad.onshape.com
  2. +
  3. Click Company in the left sidebar
  4. +
  5. The URL will show the company ID: +https://cad.onshape.com/company/<COMPANY_ID>
  6. +
  7. Leave this field empty on Education/Student/Free plans.
  8. +
+

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. My 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
    • +
    • Onshape Company ID: From step above (leave empty for EDU/Free +plans)
    • +
    +
  6. +
  7. For HMAC mode, enter the API Access Key and API Secret +Key.

    +
  8. +
  9. For OAuth2 mode:

    +

    a. Enter the OAuth2 Client ID and Client Secret from the +Onshape Developer Portal. Make sure you copy the complete secret +including any trailing = padding characters (base64 encoding).

    +

    b. Copy the OAuth2 Redirect URI shown on the form (click the +clipboard icon) and register it in your Onshape app’s redirect URLs.

    +

    c. Click Authorize with Onshape — you will be redirected to +Onshape to approve access. After approval, Onshape redirects back to +Odoo and the token is stored automatically.

    +

    d. The OAuth2 Authorized checkbox confirms the token was +obtained.

    +
  10. +
  11. Click Check Credentials — should show a green success +notification.

    +
  12. +
  13. Click Activate to enable the backend.

    +
  14. +
+
+
+

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. Click Generate Secret on the backend form to create a webhook +secret.
  2. +
  3. Click Register Webhook to register webhooks with Onshape.
      +
    • Enterprise/Professional (Company ID set): registers a single +company-wide webhook.
    • +
    • Education/Free (no Company ID): registers one webhook per +document. New documents imported later get webhooks automatically.
    • +
    +
  4. +
  5. The Webhook URL field (read-only) shows the endpoint URL. Ensure +your Odoo instance is reachable from the internet at that address +(Onshape must be able to POST to it).
  6. +
+

Clicking Register Webhook again cleans up stale duplicates and only +registers for documents that are missing a webhook. The webhook ID is +tracked on each document record.

+

Events handled:

+
    +
  • onshape.model.lifecycle.metadata — re-imports part metadata
  • +
  • onshape.model.lifecycle.createversion — syncs document on version +creation
  • +
  • onshape.workflow.transition — updates lifecycle state (Enterprise +only)
  • +
  • onshape.revision.created — marks parts as released (Enterprise +only)
  • +
  • webhook.unregister — clears tracked webhook ID when Onshape +expires it
  • +
+
+
+

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 Onshape account. 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

+ +
+
+

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..db335c660 --- /dev/null +++ b/connector_onshape/tests/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +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..71911974c --- /dev/null +++ b/connector_onshape/tests/common.py @@ -0,0 +1,193 @@ +# 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.addons.component.tests.common import TransactionComponentCase + +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": "HW-BOLT-001", + "properties": [ + {"name": "Part Number", "propertyId": "pn_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": "Project Alpha"}, + {"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", + }, + ], + }, +] + +MOCK_MASS_PROPERTIES = { + "bodies": { + "body_001": { + "mass": [0.045], + "volume": [5.73e-6], + "periphery": [0.00234], + } + } +} + +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": "HW-BOLT-001", + "partNumber": "HW-BOLT-001", + "quantity": 4, + }, + { + "name": "HW-NUT-001", + "partNumber": "HW-NUT-001", + "quantity": 4, + }, + { + "name": "Unknown Part", + "partNumber": "", + "quantity": 1, + }, + ] + } +} + + +class OnshapeTestCase(TransactionComponentCase): + """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", + "onshape_company_id": "team_test_001", + "state": "active", + } + ) + cls.product_bolt = cls.env["product.product"].create( + { + "name": "Hex Bolt 3/8-16", + "default_code": "HW-BOLT-001", + "type": "product", + } + ) + cls.product_nut = cls.env["product.product"].create( + { + "name": "Hex Nut 3/8-16", + "default_code": "HW-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_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 + return adapter diff --git a/connector_onshape/tests/test_adapter.py b/connector_onshape/tests/test_adapter.py new file mode 100644 index 000000000..10a54f572 --- /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.request") + def test_quota_exhausted_raises(self, mock_request): + from ..components.adapter import OnshapeQuotaError + + mock_resp = MagicMock() + mock_resp.status_code = 402 + mock_resp.headers = {} + mock_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..046cfbefd --- /dev/null +++ b/connector_onshape/tests/test_backend.py @@ -0,0 +1,98 @@ +# 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 + backend.action_check_credentials() + self.assertEqual(backend.state, "checked") + + 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..ad3d1d7f1 --- /dev/null +++ b/connector_onshape/tests/test_export_product.py @@ -0,0 +1,90 @@ +# 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"], "HW-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"}) + + 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_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..45c03bcba --- /dev/null +++ b/connector_onshape/tests/test_import_product.py @@ -0,0 +1,162 @@ +# 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": "HW-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": "HW-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": "HW-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": "HW-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) + self.assertEqual(product, self.product_mcmaster) + self.assertEqual(match_type, "mcmaster_catalog") + + def test_case_insensitive_match(self): + mapper = self._get_mapper() + part_data = {"name": "hw-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"], "HW-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"], "Project Alpha") + 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() + 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": "HW-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..2a9ef06cf --- /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": "HW-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": "HW-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": "HW-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..383934cb5 --- /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": "HW-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..9b8041180 --- /dev/null +++ b/connector_onshape/tests/test_webhook.py @@ -0,0 +1,115 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +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 = base64.b64encode( + hmac.new(secret.encode("utf-8"), body, digestmod=hashlib.sha256).digest() + ).decode("ascii") + + 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", + } + ) + + # Test the state mapping logic directly instead of going through + # the controller (which requires request context) + state_map = { + "release": "released", + "obsolete": "obsolete", + "in progress": "in_progress", + "pending": "pending", + } + mapped_state = state_map.get("release") + self.assertEqual(mapped_state, "released") + + bindings = self.env["onshape.product.product"].search( + [ + ("backend_id", "=", self.backend.id), + ( + "onshape_document_id.onshape_document_id", + "=", + doc.onshape_document_id, + ), + ] + ) + self.assertTrue(bindings) + bindings.write({"onshape_state": mapped_state}) + 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", + } + ) + + # Test the binding lookup + state write directly + bindings = self.env["onshape.product.product"].search( + [ + ("backend_id", "=", self.backend.id), + ( + "onshape_document_id.onshape_document_id", + "=", + doc.onshape_document_id, + ), + ] + ) + self.assertTrue(bindings) + bindings.write({"onshape_state": "released"}) + 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..ef3fefa80 --- /dev/null +++ b/connector_onshape/tests/test_wizard.py @@ -0,0 +1,54 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +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.assertTrue(wizard.backend_id) + self.assertEqual(wizard.backend_id.state, "active") + + 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() + + def test_documents_only_import(self): + """Verify the wizard can be created for document import.""" + 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..bb00b4fa9 --- /dev/null +++ b/connector_onshape/views/mrp_bom_views.xml @@ -0,0 +1,87 @@ + + + + + + Onshape BOM Bindings + onshape.mrp.bom + tree,form + + + + + mrp.bom.form.onshape + mrp.bom + + +
+ +
+ + + + +
+ + + + 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..936c325fb --- /dev/null +++ b/connector_onshape/views/onshape_backend_views.xml @@ -0,0 +1,237 @@ + + + + + + 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..f1e281504 --- /dev/null +++ b/connector_onshape/views/onshape_document_views.xml @@ -0,0 +1,151 @@ + + + + + + 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..e4035fd65 --- /dev/null +++ b/connector_onshape/views/onshape_product_views.xml @@ -0,0 +1,164 @@ + + + + + + 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..046afa69e --- /dev/null +++ b/connector_onshape/wizards/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +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..91aafd6a6 --- /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.", + default=lambda self: self._default_backend_id().auto_create_products, + ) + + @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.")) + + backend.write({"auto_create_products": self.auto_create_products}) + + if self.import_type in ("documents", "products", "boms", "full"): + backend.with_delay().action_import_documents() + + if self.import_type in ("products", "boms", "full"): + backend.with_delay().action_import_products() + + if self.import_type in ("boms", "full"): + backend.with_delay().action_import_boms() + + if self.import_type == "full": + backend.with_delay().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/requirements.txt b/requirements.txt index d3dfeea70..7f8d39be0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ # generated from manifests external_dependencies cachetools +requests 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, +)