diff --git a/connector_onshape/README.rst b/connector_onshape/README.rst new file mode 100644 index 000000000..7785f87ea --- /dev/null +++ b/connector_onshape/README.rst @@ -0,0 +1,257 @@ +================= +Onshape Connector +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a5b097e306fb250e6bb558ec033e08f2af79ffbb0c1f3ef97cbefb94e8916081 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fconnector-lightgray.png?logo=github + :target: https://github.com/OCA/connector/tree/16.0/connector_onshape + :alt: OCA/connector +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/connector-16-0/connector-16-0-connector_onshape + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/connector&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides bidirectional synchronization between Odoo and +`Onshape `_ cloud CAD/PLM platform. + +It synchronizes: + +* **Documents**: Import Onshape documents and their elements (part studios, + assemblies, drawings). +* **Products**: Bind Onshape parts to Odoo products using a 4-strategy + SKU matching algorithm (exact filename, part number, McMaster catalog, + case-insensitive). +* **Bills of Materials**: Import Onshape assembly BOMs as ``mrp.bom`` records + with component match scoring. +* **Metadata Export**: Push Odoo product SKUs and names back to Onshape + part metadata (Part Number, Description fields). +* **Webhooks**: Receive real-time notifications from Onshape for metadata + changes, workflow transitions, and revision creation. + +The module uses the OCA Connector framework with queue_job for asynchronous +processing and supports both HMAC (API Key) and OAuth2 (App Store) +authentication modes. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Onshape Developer Portal Setup +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Before configuring the Odoo backend, you need API credentials from Onshape. + +**HMAC API Keys (quickest to start):** + +1. Sign in at https://cad.onshape.com +2. Go to your **User Menu** (top-right) > **My Account** > **API keys** + (or visit https://dev-portal.onshape.com/keys directly) +3. Click **Create new API key** +4. Give it a name (e.g. ``Odoo Connector``) and select scopes: + + * ``OAuth2Read`` — read documents, parts, assemblies, metadata + * ``OAuth2Write`` — write metadata (Part Number, Description) + * ``OAuth2Delete`` — only if you need webhook management + +5. Copy the **Access key** and **Secret key** — the secret is shown only once. + +**Finding your Team / Company ID:** + +1. Go to https://cad.onshape.com +2. Click **Teams** in the left sidebar +3. Click on your team — the URL will show the team ID: + ``https://cad.onshape.com/team/`` + +**OAuth2 App Store (recommended for production):** + +HMAC keys and private OAuth2 apps count against an annual API quota +(~10,000 calls/user/year for Enterprise). Only **publicly listed App Store +apps** are exempt. To set up OAuth2: + +1. Go to https://dev-portal.onshape.com > **OAuth applications** +2. Click **Create new OAuth application** +3. Fill in: + + * **Name**: Your app name (e.g. ``Kencove Odoo Connector``) + * **Primary Format**: ``com.yourcompany.odoo-connector`` (cannot change later) + * **Redirect URLs**: ``https://your-odoo.com/connector_onshape/oauth/callback`` + * **OAuth Scopes**: ``OAuth2Read``, ``OAuth2Write`` + +4. For quota exemption, submit the app for App Store review + by emailing ``onshape-developer-relations@ptc.com`` + +Odoo Backend Configuration +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Install the ``connector_onshape`` module. +2. Go to **Onshape > Configuration > Backends** and create a new backend. +3. Fill in the connection details: + + * **Base URL**: ``https://cad.onshape.com`` (default) + * **Authentication Mode**: HMAC or OAuth2 + * **API Key / Secret**: From step above (HMAC mode) + * **Team / Company ID**: From step above + +4. Click **Check Credentials** — should show a green success notification. +5. Click **Activate** to enable the backend. + +Import Settings +~~~~~~~~~~~~~~~ + +* **Auto-create Products**: When enabled, creates new Odoo products for + Onshape parts that don't match any existing SKU. When disabled, unmatched + parts are skipped (no binding created). +* **Default Product Category**: Category assigned to auto-created products. +* **Import Products Since**: Only import parts modified after this date + (for incremental sync). + +Webhooks (Real-Time Sync) +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Webhooks push Onshape changes to Odoo in real time instead of waiting +for the next scheduled sync. + +1. In Odoo, note your backend ID (visible in the URL when viewing the backend + form, e.g. ``/web#id=1&model=onshape.backend``). + +2. Your webhook URL is: + ``https://your-odoo-instance.com/connector_onshape/webhook/`` + +3. Generate a webhook secret (any random string, e.g. ``openssl rand -hex 32``) + and enter it in the **Webhook Secret** field on the backend form. + +4. Register the webhook in Onshape. You can do this via the Onshape API + or by clicking the **Register Webhook** button (if available) on the backend. + The module listens for these events: + + * ``onshape.model.lifecycle.metadata`` — re-imports part metadata + * ``onshape.workflow.transition`` — updates lifecycle state + * ``onshape.revision.created`` — marks parts as released + * ``onshape.model.lifecycle.createversion`` — logs version creation + +5. Ensure your Odoo instance is reachable from the internet (Onshape must + be able to POST to the webhook URL). + +Scheduled Sync (Cron Jobs) +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Three cron jobs are created (disabled by default): + +* **Onshape: Import Documents** — every 6 hours +* **Onshape: Import Products** — every 6 hours +* **Onshape: Import BOMs** — every 12 hours + +Enable them in **Settings > Technical > Automation > Scheduled Actions** +when you're ready for automatic background synchronization. + +Usage +===== + +Import Documents +~~~~~~~~~~~~~~~~ + +Click **Import Documents** on the backend form to fetch all Onshape +documents from your team. Documents are created with their elements +(part studios, assemblies, drawings). + +Import Products +~~~~~~~~~~~~~~~ + +Click **Import Products** to scan all part studio elements and create +product bindings. The module uses a 4-strategy matching algorithm: + +1. **Exact filename**: Part name matches an Odoo product SKU +2. **Part number**: Onshape Part Number metadata matches an Odoo SKU +3. **McMaster catalog**: Extracted catalog numbers (e.g., 90185A632) match +4. **Case-insensitive**: Fallback case-insensitive match + +Unmatched parts will be auto-created as products if configured. + +Import BOMs +~~~~~~~~~~~ + +Click **Import BOMs** to fetch assembly BOMs from Onshape and create +``mrp.bom`` records. Each BOM includes a **match score** indicating +what percentage of Onshape components were matched to Odoo products. + +Export Part Numbers +~~~~~~~~~~~~~~~~~~~ + +Click **Export Part Numbers** to push Odoo product SKUs and names +back to Onshape. This writes the ``default_code`` as "Part Number" +and ``name`` as "Description" in Onshape metadata. + +Automatic Export +~~~~~~~~~~~~~~~~ + +When a product's ``default_code`` or ``name`` is changed in Odoo, +a background job is automatically queued to export the update to +Onshape (if the product is bound to an Onshape part). + +Import Wizard +~~~~~~~~~~~~~ + +Use **Onshape > Onshape Data > Import from Onshape** for a guided +import process with options for documents-only, documents+products, +or full sync including BOMs. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Kencove Farm Fence Supplies + +Contributors +~~~~~~~~~~~~ + +* Kencove Farm Fence Supplies + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/connector `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/connector_onshape/__init__.py b/connector_onshape/__init__.py new file mode 100644 index 000000000..a11487d3e --- /dev/null +++ b/connector_onshape/__init__.py @@ -0,0 +1 @@ +from . import components, controllers, models, wizards diff --git a/connector_onshape/__manifest__.py b/connector_onshape/__manifest__.py new file mode 100644 index 000000000..7f1823b0a --- /dev/null +++ b/connector_onshape/__manifest__.py @@ -0,0 +1,37 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Onshape Connector", + "version": "16.0.1.0.0", + "category": "Connector", + "summary": "Synchronize products and BOMs with Onshape PLM", + "author": "Kencove Farm Fence Supplies, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/connector", + "license": "AGPL-3", + "depends": [ + "connector", + "component", + "component_event", + "queue_job", + "product", + "mrp", + ], + "external_dependencies": { + "python": [], + }, + "data": [ + "security/onshape_security.xml", + "security/ir.model.access.csv", + "data/queue_job_channel_data.xml", + "data/queue_job_function_data.xml", + "data/ir_cron_data.xml", + "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": True, +} diff --git a/connector_onshape/components/__init__.py b/connector_onshape/components/__init__.py new file mode 100644 index 000000000..1d39f9ae5 --- /dev/null +++ b/connector_onshape/components/__init__.py @@ -0,0 +1 @@ +from . import adapter, binder, core, exporter, importer, listener, mapper diff --git a/connector_onshape/components/adapter.py b/connector_onshape/components/adapter.py new file mode 100644 index 000000000..bbe08f410 --- /dev/null +++ b/connector_onshape/components/adapter.py @@ -0,0 +1,353 @@ +# 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 time +from datetime import datetime +from urllib.parse import urlencode + +import requests + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + +MAX_RETRIES = 3 +RETRY_BACKOFF = 5 + + +class OnshapeAdapter(Component): + """API adapter for Onshape REST API. + + Supports HMAC authentication (ported from onshape_utils.py) + and OAuth2 bearer tokens (future). + + Handles rate limiting (429), quota exhaustion (402), and retries. + """ + + _name = "onshape.adapter" + _inherit = "onshape.base" + _usage = "backend.adapter" + + # --- Authentication --- + + def _get_backend(self): + return self.collection + + def _canonical_query(self, params): + if not params: + return "" + return urlencode(sorted(params.items()), doseq=True) + + def _hmac_headers(self, method, path, query_params=None, content_type=""): + """Generate HMAC auth headers. + + Ported from onshape_utils.py lines 52-94. + Signing string: method, nonce, date, content_type, path, query + All lowercased, HMAC-SHA256 signed, Base64 encoded. + """ + backend = self._get_backend() + method = method.upper() + date = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT") + 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 _check_retryable(self, resp, attempt): + """Return wait seconds if request should be retried, else None.""" + if resp.status_code == 402: + _logger.error( + "Onshape API quota exhausted (402). " "Consider switching to OAuth2." + ) + raise OnshapeQuotaError( + "Onshape API quota exhausted. " + "Switch to OAuth2 App Store authentication." + ) + if resp.status_code == 429: + wait = RETRY_BACKOFF * (attempt + 1) + retry_after = resp.headers.get("Retry-After") + if retry_after: + try: + wait = int(retry_after) + except (TypeError, ValueError): + _logger.debug( + "Unexpected Retry-After header %r; using backoff %ss", + retry_after, + wait, + ) + _logger.warning("Onshape rate limited (429), waiting %ss", wait) + return wait + if resp.status_code >= 500: + wait = RETRY_BACKOFF * (attempt + 1) + _logger.warning( + "Onshape server error %s, retry in %ss", + resp.status_code, + wait, + ) + return wait + return None + + def _request(self, method, path, query_params=None, json_body=None, raw=False): + """Make an authenticated API request with retry and rate-limit handling. + + 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) + + wait = self._check_retryable(resp, attempt) + if wait is not None: + time.sleep(wait) + continue + + if raw: + return resp + resp.raise_for_status() + if resp.status_code == 204: + return {} + return resp.json() + + except requests.exceptions.RequestException: + if attempt < MAX_RETRIES - 1: + time.sleep(RETRY_BACKOFF) + continue + raise + + if raw: + return last_resp + if last_resp is not None: + last_resp.raise_for_status() + return {} + + # --- Credential check --- + + def check_credentials(self): + try: + resp = self._request( + "GET", + "/api/v6/documents", + query_params={"limit": 1}, + raw=True, + ) + if resp.status_code == 200: + return True, "Credentials verified (documents endpoint OK)." + if resp.status_code == 401: + return False, "Unauthorized (401). Check API key/secret." + return ( + False, + f"Unexpected status {resp.status_code}: " f"{resp.text[:200]}", + ) + except OnshapeQuotaError as e: + return False, str(e) + except Exception as e: + return False, f"Connection error: {e}" + + # --- Document endpoints --- + + def search_documents(self, owner_id=None, offset=0, limit=20): + params = {"offset": offset, "limit": limit, "sortColumn": "name"} + if owner_id: + params["owner"] = owner_id + params["ownerType"] = 1 # team + return self._request("GET", "/api/v6/documents", query_params=params) + + def read_document(self, document_id): + return self._request("GET", f"/api/v6/documents/{document_id}") + + def read_document_elements(self, document_id, workspace_id): + return self._request( + "GET", + f"/api/v6/documents/d/{document_id}/w/{workspace_id}/elements", + ) + + # --- Part / Metadata endpoints --- + + def read_parts(self, document_id, workspace_id, element_id): + return self._request( + "GET", + f"/api/v6/parts/d/{document_id}/w/{workspace_id}" f"/e/{element_id}", + ) + + def read_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) + return None + + # --- Webhooks --- + + def register_webhook(self, url, events=None): + backend = self._get_backend() + if events is None: + events = [ + "onshape.model.lifecycle.metadata", + "onshape.workflow.transition", + "onshape.revision.created", + "onshape.model.lifecycle.createversion", + ] + body = { + "url": url, + "events": events, + } + if backend.team_id: + body["companyId"] = backend.team_id + return self._request("POST", "/api/v6/webhooks", json_body=body) + + def list_webhooks(self): + return self._request("GET", "/api/v6/webhooks") + + def delete_webhook(self, webhook_id): + return self._request("DELETE", f"/api/v6/webhooks/{webhook_id}") + + +class OnshapeQuotaError(Exception): + """Raised when Onshape returns 402 (quota exhausted).""" diff --git a/connector_onshape/components/binder.py b/connector_onshape/components/binder.py new file mode 100644 index 000000000..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..7404bf33c --- /dev/null +++ b/connector_onshape/components/exporter.py @@ -0,0 +1,99 @@ +# 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: + binding.write({"sync_date": fields.Datetime.now()}) + 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..fc85eb05d --- /dev/null +++ b/connector_onshape/components/importer.py @@ -0,0 +1,549 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import hashlib +import json +import logging + +from odoo import fields + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class OnshapeDocumentBatchImporter(Component): + """Batch importer for Onshape documents. + + Lists all documents from the Onshape team and queues + individual record imports via queue_job. + """ + + _name = "onshape.document.batch.importer" + _inherit = "onshape.base" + _usage = "batch.importer" + _apply_on = "onshape.document" + + def run(self, backend, **kwargs): + adapter = self.component(usage="backend.adapter") + offset = 0 + limit = 50 + total_queued = 0 + + while True: + result = adapter.search_documents( + owner_id=backend.team_id, + offset=offset, + limit=limit, + ) + items = result.get("items", []) + if not items: + break + + for doc_data in items: + doc_id = doc_data.get("id") + if not doc_id: + continue + existing = self.env["onshape.document"].search( + [ + ("backend_id", "=", backend.id), + ("onshape_document_id", "=", doc_id), + ], + limit=1, + ) + if existing: + # Update name/timestamps + vals = {"name": doc_data.get("name", existing.name)} + modified = doc_data.get("modifiedAt") + if modified: + vals["modified_at"] = modified + existing.write(vals) + else: + self._import_document(backend, doc_data) + total_queued += 1 + + if len(items) < limit: + break + offset += limit + + _logger.info( + "Onshape document batch import: processed %d documents", + total_queued, + ) + + def _import_document(self, backend, doc_data): + """Create an onshape.document and import its elements.""" + default_ws = doc_data.get("defaultWorkspace") or {} + workspace_id = ( + default_ws.get("id") + or default_ws.get("workspaceId") + or doc_data.get("defaultWorkspaceId", "") + ) + owner_data = doc_data.get("owner") or {} + + vals = { + "backend_id": backend.id, + "name": doc_data.get("name", "Untitled"), + "onshape_document_id": doc_data["id"], + "onshape_default_workspace_id": workspace_id, + "owner": owner_data.get("name", ""), + "created_at": doc_data.get("createdAt"), + "modified_at": doc_data.get("modifiedAt"), + } + document = self.env["onshape.document"].create(vals) + + # Import elements + if workspace_id: + self._import_elements(backend, document, workspace_id) + + return document + + def _import_elements(self, backend, document, workspace_id): + adapter = self.component(usage="backend.adapter") + try: + elements = adapter.read_document_elements( + document.onshape_document_id, + workspace_id, + ) + except Exception: + _logger.warning( + "Could not fetch elements for document %s", + document.onshape_document_id, + ) + return + + if not isinstance(elements, list): + return + + type_map = { + "PARTSTUDIO": "partstudio", + "ASSEMBLY": "assembly", + "DRAWING": "drawing", + "BLOB": "blob", + "APPLICATION": "application", + } + + has_assembly = False + has_partstudio = False + + for elem in elements: + elem_type_raw = elem.get("elementType", "").upper() + elem_type = type_map.get(elem_type_raw) + if elem_type == "assembly": + has_assembly = True + elif elem_type == "partstudio": + has_partstudio = True + + self.env["onshape.document.element"].create( + { + "document_id": document.id, + "name": elem.get("name", ""), + "onshape_element_id": elem.get("id", ""), + "element_type": elem_type, + "microversion_id": elem.get("microversionId", ""), + } + ) + + # Infer document type + if has_assembly: + document.document_type = "assembly" + elif has_partstudio: + document.document_type = "part" + + +class OnshapeProductBatchImporter(Component): + """Batch importer for Onshape product bindings. + + Iterates over all documents with part studio elements and + imports parts as product bindings. + """ + + _name = "onshape.product.batch.importer" + _inherit = "onshape.base" + _usage = "batch.importer" + _apply_on = "onshape.product.product" + + def run(self, backend, 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..baed10cac --- /dev/null +++ b/connector_onshape/components/mapper.py @@ -0,0 +1,223 @@ +# 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 match_product(self, part_data): + """Try to match an Onshape part to an existing Odoo product. + + 4-strategy matching ported from match_cad_to_odoo.py: + 1. Exact part name match against default_code + 2. Part number from metadata against default_code + 3. McMaster catalog number extraction + 4. Case-insensitive match + + Returns (product.product recordset, match_type) or (None, None). + """ + Product = self.env["product.product"] + part_name = part_data.get("name", "").strip() + part_number = "" + + for prop in part_data.get("properties", []): + if prop.get("name") == "Part Number": + part_number = (prop.get("value") or "").strip() + break + + # Clean name: strip CAD extensions and version suffixes + base_name = re.sub(r"\.(ipt|iam|dwg|idw)$", "", part_name, flags=re.IGNORECASE) + base_no_ver = VERSION_SUFFIX_RE.sub("", base_name) + + # Strategy 1: Exact name match + if base_no_ver: + product = Product.search([("default_code", "=", base_no_ver)], limit=1) + if product: + return product, "exact_filename" + + product = Product.search([("default_code", "=", base_name)], limit=1) + if product: + return product, "exact_filename_versioned" + + # Strategy 2: Part number from Onshape metadata + if part_number: + product = Product.search([("default_code", "=", part_number)], limit=1) + if product: + return product, "part_name" + + # Part number prefix (before underscore) + pn_prefix = part_number.split("_")[0].strip() + if pn_prefix and pn_prefix != part_number: + product = Product.search([("default_code", "=", pn_prefix)], limit=1) + if product: + return product, "part_name_prefix" + + # Strategy 3: McMaster catalog number + if base_no_ver: + mcmaster_match = MCMASTER_RE.search(base_no_ver) + if mcmaster_match: + cat_num = mcmaster_match.group(1) + product = Product.search([("default_code", "=", cat_num)], limit=1) + if product: + return product, "mcmaster_catalog" + + # Strategy 4: Case-insensitive match + if base_no_ver: + product = Product.search([("default_code", "=ilike", base_no_ver)], limit=1) + if product: + return product, "case_insensitive" + + return None, None + + +class OnshapeProductExportMapper(Component): + """Map Odoo product data to Onshape metadata fields. + + 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..4496395f5 --- /dev/null +++ b/connector_onshape/controllers/__init__.py @@ -0,0 +1 @@ +from . import webhook diff --git a/connector_onshape/controllers/webhook.py b/connector_onshape/controllers/webhook.py new file mode 100644 index 000000000..59584667b --- /dev/null +++ b/connector_onshape/controllers/webhook.py @@ -0,0 +1,189 @@ +# 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 + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class OnshapeWebhookController(http.Controller): + """Receive Onshape webhook events. + + Route: /connector_onshape/webhook/ + + Events handled: + - onshape.model.lifecycle.metadata → re-import part metadata + - onshape.workflow.transition → update lifecycle state + - onshape.revision.created → mark as released + - onshape.model.lifecycle.createversion → log version creation + + Security: Validate HMAC signature, then re-fetch from API + (never trust webhook payload directly). + """ + + @http.route( + "/connector_onshape/webhook/", + type="json", + auth="none", + methods=["POST"], + csrf=False, + ) + def webhook(self, backend_id, **kwargs): + backend = request.env["onshape.backend"].sudo().browse(backend_id).exists() + if not backend: + _logger.warning("Webhook received for unknown backend %s", backend_id) + return {"status": "error", "message": "Unknown backend"} + + # Validate HMAC signature (required) + raw_body = request.httprequest.get_data() + if not backend.webhook_secret: + _logger.warning( + "Webhook secret not configured for backend %s. " + "Rejecting unauthenticated webhook.", + backend_id, + ) + return {"status": "error", "message": "Webhook secret not configured"} + + signature = request.httprequest.headers.get( + "X-onshape-webhook-signature-primary", "" + ) + if not self._validate_signature(raw_body, backend.webhook_secret, signature): + _logger.warning( + "Webhook signature validation failed for backend %s", + backend_id, + ) + return {"status": "error", "message": "Invalid signature"} + + try: + payload = json.loads(raw_body) + except (json.JSONDecodeError, TypeError): + return {"status": "error", "message": "Invalid JSON"} + + event = payload.get("event", "") + _logger.info( + "Onshape webhook received: event=%s backend=%s", + event, + backend_id, + ) + + # Dispatch by event type + handler = self._get_event_handler(event) + if handler: + handler(backend, payload) + return {"status": "ok"} + + _logger.debug("Unhandled webhook event: %s", event) + return {"status": "ok", "message": "Event not handled"} + + def _validate_signature(self, raw_body, secret, signature): + expected = base64.b64encode( + hmac.new( + secret.encode("utf-8"), + raw_body, + digestmod=hashlib.sha256, + ).digest() + ).decode("ascii") + return hmac.compare_digest(expected, signature) + + def _get_event_handler(self, event): + handlers = { + "onshape.model.lifecycle.metadata": self._handle_metadata_change, + "onshape.workflow.transition": self._handle_workflow_transition, + "onshape.revision.created": self._handle_revision_created, + "onshape.model.lifecycle.createversion": (self._handle_version_created), + } + return handlers.get(event) + + def _handle_metadata_change(self, backend, payload): + """Re-import part metadata when it changes in Onshape. + + Queues a 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 + + # 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): + """Log version creation (informational).""" + doc_id = payload.get("documentId", "") + version_name = payload.get("versionName", "") + _logger.info( + "Onshape version created: doc=%s version=%s", + doc_id, + version_name, + ) diff --git a/connector_onshape/data/ir_cron_data.xml b/connector_onshape/data/ir_cron_data.xml new file mode 100644 index 000000000..0f4acbbe4 --- /dev/null +++ b/connector_onshape/data/ir_cron_data.xml @@ -0,0 +1,40 @@ + + + + + Onshape: Import Documents + + code + model.cron_import_documents() + 6 + hours + -1 + + + + + + Onshape: Import Products + + code + model.cron_import_products() + 6 + hours + -1 + + + + + + Onshape: Import BOMs + + code + model.cron_import_boms() + 12 + hours + -1 + + + + + diff --git a/connector_onshape/data/queue_job_channel_data.xml b/connector_onshape/data/queue_job_channel_data.xml new file mode 100644 index 000000000..7105d914a --- /dev/null +++ b/connector_onshape/data/queue_job_channel_data.xml @@ -0,0 +1,9 @@ + + + + + onshape + + + + diff --git a/connector_onshape/data/queue_job_function_data.xml b/connector_onshape/data/queue_job_function_data.xml new file mode 100644 index 000000000..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..f47fb5b01 --- /dev/null +++ b/connector_onshape/models/__init__.py @@ -0,0 +1,9 @@ +from . import ( + mrp_bom, + onshape_backend, + onshape_document, + onshape_mrp_bom, + onshape_product_product, + product_product, + product_template, +) diff --git a/connector_onshape/models/mrp_bom.py b/connector_onshape/models/mrp_bom.py new file mode 100644 index 000000000..a9b0ed5e1 --- /dev/null +++ b/connector_onshape/models/mrp_bom.py @@ -0,0 +1,24 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class MrpBom(models.Model): + _inherit = "mrp.bom" + + onshape_bind_ids = fields.One2many( + "onshape.mrp.bom", + "odoo_id", + string="Onshape Bindings", + ) + onshape_linked = fields.Boolean( + string="Linked to Onshape", + compute="_compute_onshape_linked", + store=True, + ) + + @api.depends("onshape_bind_ids") + def _compute_onshape_linked(self): + for rec in self: + rec.onshape_linked = bool(rec.onshape_bind_ids) diff --git a/connector_onshape/models/onshape_backend.py b/connector_onshape/models/onshape_backend.py new file mode 100644 index 000000000..34d8c33b8 --- /dev/null +++ b/connector_onshape/models/onshape_backend.py @@ -0,0 +1,201 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class OnshapeBackend(models.Model): + _name = "onshape.backend" + _description = "Onshape Backend" + _inherit = "connector.backend" + + name = fields.Char(required=True, default="Onshape") + base_url = fields.Char( + string="Base URL", + required=True, + default="https://cad.onshape.com", + ) + auth_mode = fields.Selection( + [("hmac", "HMAC (API Key)"), ("oauth2", "OAuth2 (App Store)")], + string="Authentication Mode", + required=True, + default="hmac", + ) + api_key = fields.Char(string="API Access Key", groups="base.group_system") + api_secret = fields.Char(string="API Secret Key", groups="base.group_system") + oauth2_client_id = fields.Char( + string="OAuth2 Client ID", groups="base.group_system" + ) + oauth2_client_secret = fields.Char( + string="OAuth2 Client Secret", groups="base.group_system" + ) + oauth2_token = fields.Text(string="OAuth2 Token (JSON)", groups="base.group_system") + team_id = fields.Char(string="Onshape Team / Company ID") + webhook_secret = fields.Char(groups="base.group_system") + state = fields.Selection( + [("draft", "Draft"), ("checked", "Checked"), ("active", "Active")], + default="draft", + required=True, + ) + import_products_since = fields.Datetime() + auto_create_products = fields.Boolean( + string="Auto-create Products", + help="Automatically create Odoo products for unmatched Onshape parts.", + ) + default_product_category_id = fields.Many2one( + "product.category", + string="Default Product Category", + help="Category assigned to auto-created products.", + ) + document_ids = fields.One2many("onshape.document", "backend_id", string="Documents") + document_count = fields.Integer(compute="_compute_document_count") + product_binding_ids = fields.One2many( + "onshape.product.product", "backend_id", string="Product Bindings" + ) + product_binding_count = fields.Integer(compute="_compute_product_binding_count") + bom_binding_ids = fields.One2many( + "onshape.mrp.bom", "backend_id", string="BOM Bindings" + ) + bom_binding_count = fields.Integer(compute="_compute_bom_binding_count") + + @api.depends("document_ids") + def _compute_document_count(self): + for rec in self: + rec.document_count = len(rec.document_ids) + + @api.depends("product_binding_ids") + def _compute_product_binding_count(self): + for rec in self: + rec.product_binding_count = len(rec.product_binding_ids) + + @api.depends("bom_binding_ids") + def _compute_bom_binding_count(self): + for rec in self: + rec.bom_binding_count = len(rec.bom_binding_ids) + + def _get_adapter(self): + self.ensure_one() + with self.work_on("onshape.backend") as work: + return work.component(usage="backend.adapter") + + def action_check_credentials(self): + self.ensure_one() + adapter = self._get_adapter() + ok, message = adapter.check_credentials() + if ok: + self.write({"state": "checked"}) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Credentials Verified"), + "message": message, + "type": "success", + "sticky": False, + }, + } + raise UserError(_("Credential check failed: %s") % message) + + def action_activate(self): + self.ensure_one() + if self.state != "checked": + raise UserError(_("Please check credentials first.")) + self.write({"state": "active"}) + + def action_import_documents(self): + self.ensure_one() + self._check_active() + with self.work_on("onshape.document") as work: + importer = work.component(usage="batch.importer") + importer.run(backend=self) + + def action_import_products(self): + self.ensure_one() + self._check_active() + with self.work_on("onshape.product.product") as work: + importer = work.component(usage="batch.importer") + importer.run(backend=self) + + def action_import_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 _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..0f03d7f07 --- /dev/null +++ b/connector_onshape/models/onshape_document.py @@ -0,0 +1,122 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class OnshapeDocument(models.Model): + _name = "onshape.document" + _description = "Onshape Document" + _order = "name" + + backend_id = fields.Many2one( + "onshape.backend", + string="Backend", + required=True, + ondelete="cascade", + index=True, + ) + name = fields.Char(required=True, index=True) + onshape_document_id = fields.Char( + string="Document ID", + required=True, + index=True, + help="24-character hex Onshape document identifier.", + ) + onshape_default_workspace_id = fields.Char( + string="Default Workspace ID", + help="Active workspace for this document.", + ) + document_type = fields.Selection( + [ + ("assembly", "Assembly"), + ("part", "Part Studio"), + ("drawing", "Drawing"), + ("other", "Other"), + ], + default="other", + ) + thumbnail = fields.Binary(attachment=True) + element_ids = fields.One2many( + "onshape.document.element", "document_id", string="Elements" + ) + product_binding_ids = fields.One2many( + "onshape.product.product", + "onshape_document_id", + string="Product Bindings", + ) + bom_binding_ids = fields.One2many( + "onshape.mrp.bom", + "onshape_document_id", + string="BOM Bindings", + ) + onshape_url = fields.Char( + string="Onshape URL", + compute="_compute_onshape_url", + store=True, + ) + owner = fields.Char() + created_at = fields.Datetime(string="Created in Onshape") + modified_at = fields.Datetime(string="Last Modified in Onshape") + + _sql_constraints = [ + ( + "unique_document", + "unique(backend_id, onshape_document_id)", + "This Onshape document is already registered on this backend.", + ), + ] + + @api.depends("backend_id.base_url", "onshape_document_id") + def _compute_onshape_url(self): + for rec in self: + if rec.backend_id.base_url and rec.onshape_document_id: + rec.onshape_url = ( + f"{rec.backend_id.base_url}" f"/documents/{rec.onshape_document_id}" + ) + else: + rec.onshape_url = False + + 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..a382d643a --- /dev/null +++ b/connector_onshape/models/onshape_mrp_bom.py @@ -0,0 +1,79 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class OnshapeMrpBom(models.Model): + _name = "onshape.mrp.bom" + _description = "Onshape BOM Binding" + _inherits = {"mrp.bom": "odoo_id"} + + odoo_id = fields.Many2one( + "mrp.bom", + string="Odoo BOM", + required=True, + ondelete="cascade", + index=True, + ) + backend_id = fields.Many2one( + "onshape.backend", + string="Backend", + required=True, + ondelete="restrict", + index=True, + ) + external_id = fields.Char( + string="External ID", + index=True, + help="Compound key: document_id/element_id", + ) + onshape_document_id = fields.Many2one( + "onshape.document", + string="Onshape Document", + ondelete="set null", + index=True, + ) + onshape_element_id = fields.Char(string="Element ID") + match_score = fields.Float( + string="Component Match Score", + digits=(5, 3), + help="Percentage of components matched between Onshape and Odoo.", + ) + last_bom_hash = fields.Char( + string="BOM Hash", + help="Hash of BOM contents for change detection.", + ) + sync_date = fields.Datetime(string="Last Sync Date") + onshape_url = fields.Char( + string="Onshape URL", + compute="_compute_onshape_url", + store=True, + ) + + _sql_constraints = [ + ( + "unique_binding", + "unique(backend_id, external_id)", + "This Onshape assembly BOM is already bound on this backend.", + ), + ] + + @api.depends( + "onshape_document_id.backend_id.base_url", + "onshape_document_id.onshape_document_id", + "onshape_element_id", + ) + def _compute_onshape_url(self): + for rec in self: + doc = rec.onshape_document_id + if doc and doc.backend_id.base_url and doc.onshape_document_id: + workspace = doc.onshape_default_workspace_id or "" + elem = rec.onshape_element_id or "" + rec.onshape_url = ( + f"{doc.backend_id.base_url}" + f"/documents/{doc.onshape_document_id}" + f"/w/{workspace}/e/{elem}" + ) + else: + rec.onshape_url = False diff --git a/connector_onshape/models/onshape_product_product.py b/connector_onshape/models/onshape_product_product.py new file mode 100644 index 000000000..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.rst b/connector_onshape/readme/CONFIGURE.rst new file mode 100644 index 000000000..52869d48e --- /dev/null +++ b/connector_onshape/readme/CONFIGURE.rst @@ -0,0 +1,107 @@ +Onshape Developer Portal Setup +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Before configuring the Odoo backend, you need API credentials from Onshape. + +**HMAC API Keys (quickest to start):** + +1. Sign in at https://cad.onshape.com +2. Go to your **User Menu** (top-right) > **My Account** > **API keys** + (or visit https://dev-portal.onshape.com/keys directly) +3. Click **Create new API key** +4. Give it a name (e.g. ``Odoo Connector``) and select scopes: + + * ``OAuth2Read`` — read documents, parts, assemblies, metadata + * ``OAuth2Write`` — write metadata (Part Number, Description) + * ``OAuth2Delete`` — only if you need webhook management + +5. Copy the **Access key** and **Secret key** — the secret is shown only once. + +**Finding your Team / Company ID:** + +1. Go to https://cad.onshape.com +2. Click **Teams** in the left sidebar +3. Click on your team — the URL will show the team ID: + ``https://cad.onshape.com/team/`` + +**OAuth2 App Store (recommended for production):** + +HMAC keys and private OAuth2 apps count against an annual API quota +(~10,000 calls/user/year for Enterprise). Only **publicly listed App Store +apps** are exempt. To set up OAuth2: + +1. Go to https://dev-portal.onshape.com > **OAuth applications** +2. Click **Create new OAuth application** +3. Fill in: + + * **Name**: Your app name (e.g. ``Kencove Odoo Connector``) + * **Primary Format**: ``com.yourcompany.odoo-connector`` (cannot change later) + * **Redirect URLs**: ``https://your-odoo.com/connector_onshape/oauth/callback`` + * **OAuth Scopes**: ``OAuth2Read``, ``OAuth2Write`` + +4. For quota exemption, submit the app for App Store review + by emailing ``onshape-developer-relations@ptc.com`` + +Odoo Backend Configuration +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Install the ``connector_onshape`` module. +2. Go to **Onshape > Configuration > Backends** and create a new backend. +3. Fill in the connection details: + + * **Base URL**: ``https://cad.onshape.com`` (default) + * **Authentication Mode**: HMAC or OAuth2 + * **API Key / Secret**: From step above (HMAC mode) + * **Team / Company ID**: From step above + +4. Click **Check Credentials** — should show a green success notification. +5. Click **Activate** to enable the backend. + +Import Settings +~~~~~~~~~~~~~~~ + +* **Auto-create Products**: When enabled, creates new Odoo products for + Onshape parts that don't match any existing SKU. When disabled, unmatched + parts are skipped (no binding created). +* **Default Product Category**: Category assigned to auto-created products. +* **Import Products Since**: Only import parts modified after this date + (for incremental sync). + +Webhooks (Real-Time Sync) +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Webhooks push Onshape changes to Odoo in real time instead of waiting +for the next scheduled sync. + +1. In Odoo, note your backend ID (visible in the URL when viewing the backend + form, e.g. ``/web#id=1&model=onshape.backend``). + +2. Your webhook URL is: + ``https://your-odoo-instance.com/connector_onshape/webhook/`` + +3. Generate a webhook secret (any random string, e.g. ``openssl rand -hex 32``) + and enter it in the **Webhook Secret** field on the backend form. + +4. Register the webhook in Onshape. You can do this via the Onshape API + or by clicking the **Register Webhook** button (if available) on the backend. + The module listens for these events: + + * ``onshape.model.lifecycle.metadata`` — re-imports part metadata + * ``onshape.workflow.transition`` — updates lifecycle state + * ``onshape.revision.created`` — marks parts as released + * ``onshape.model.lifecycle.createversion`` — logs version creation + +5. Ensure your Odoo instance is reachable from the internet (Onshape must + be able to POST to the webhook URL). + +Scheduled Sync (Cron Jobs) +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Three cron jobs are created (disabled by default): + +* **Onshape: Import Documents** — every 6 hours +* **Onshape: Import Products** — every 6 hours +* **Onshape: Import BOMs** — every 12 hours + +Enable them in **Settings > Technical > Automation > Scheduled Actions** +when you're ready for automatic background synchronization. diff --git a/connector_onshape/readme/CONTRIBUTORS.rst b/connector_onshape/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..6991288e5 --- /dev/null +++ b/connector_onshape/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Kencove Farm Fence Supplies diff --git a/connector_onshape/readme/DESCRIPTION.rst b/connector_onshape/readme/DESCRIPTION.rst new file mode 100644 index 000000000..f38302125 --- /dev/null +++ b/connector_onshape/readme/DESCRIPTION.rst @@ -0,0 +1,20 @@ +This module provides bidirectional synchronization between Odoo and +`Onshape `_ cloud CAD/PLM platform. + +It synchronizes: + +* **Documents**: Import Onshape documents and their elements (part studios, + assemblies, drawings). +* **Products**: Bind Onshape parts to Odoo products using a 4-strategy + SKU matching algorithm (exact filename, part number, McMaster catalog, + case-insensitive). +* **Bills of Materials**: Import Onshape assembly BOMs as ``mrp.bom`` records + with component match scoring. +* **Metadata Export**: Push Odoo product SKUs and names back to Onshape + part metadata (Part Number, Description fields). +* **Webhooks**: Receive real-time notifications from Onshape for metadata + changes, workflow transitions, and revision creation. + +The module uses the OCA Connector framework with queue_job for asynchronous +processing and supports both HMAC (API Key) and OAuth2 (App Store) +authentication modes. diff --git a/connector_onshape/readme/USAGE.rst b/connector_onshape/readme/USAGE.rst new file mode 100644 index 000000000..c0b727994 --- /dev/null +++ b/connector_onshape/readme/USAGE.rst @@ -0,0 +1,47 @@ +Import Documents +~~~~~~~~~~~~~~~~ + +Click **Import Documents** on the backend form to fetch all Onshape +documents from your team. Documents are created with their elements +(part studios, assemblies, drawings). + +Import Products +~~~~~~~~~~~~~~~ + +Click **Import Products** to scan all part studio elements and create +product bindings. The module uses a 4-strategy matching algorithm: + +1. **Exact filename**: Part name matches an Odoo product SKU +2. **Part number**: Onshape Part Number metadata matches an Odoo SKU +3. **McMaster catalog**: Extracted catalog numbers (e.g., 90185A632) match +4. **Case-insensitive**: Fallback case-insensitive match + +Unmatched parts will be auto-created as products if configured. + +Import BOMs +~~~~~~~~~~~ + +Click **Import BOMs** to fetch assembly BOMs from Onshape and create +``mrp.bom`` records. Each BOM includes a **match score** indicating +what percentage of Onshape components were matched to Odoo products. + +Export Part Numbers +~~~~~~~~~~~~~~~~~~~ + +Click **Export Part Numbers** to push Odoo product SKUs and names +back to Onshape. This writes the ``default_code`` as "Part Number" +and ``name`` as "Description" in Onshape metadata. + +Automatic Export +~~~~~~~~~~~~~~~~ + +When a product's ``default_code`` or ``name`` is changed in Odoo, +a background job is automatically queued to export the update to +Onshape (if the product is bound to an Onshape part). + +Import Wizard +~~~~~~~~~~~~~ + +Use **Onshape > Onshape Data > Import from Onshape** for a guided +import process with options for documents-only, documents+products, +or full sync including BOMs. diff --git a/connector_onshape/security/ir.model.access.csv b/connector_onshape/security/ir.model.access.csv new file mode 100644 index 000000000..61bc57fee --- /dev/null +++ b/connector_onshape/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_onshape_backend_user,onshape.backend user,model_onshape_backend,group_onshape_user,1,0,0,0 +access_onshape_backend_manager,onshape.backend manager,model_onshape_backend,group_onshape_manager,1,1,1,1 +access_onshape_document_user,onshape.document user,model_onshape_document,group_onshape_user,1,0,0,0 +access_onshape_document_manager,onshape.document manager,model_onshape_document,group_onshape_manager,1,1,1,1 +access_onshape_document_element_user,onshape.document.element user,model_onshape_document_element,group_onshape_user,1,0,0,0 +access_onshape_document_element_manager,onshape.document.element manager,model_onshape_document_element,group_onshape_manager,1,1,1,1 +access_onshape_product_product_user,onshape.product.product user,model_onshape_product_product,group_onshape_user,1,0,0,0 +access_onshape_product_product_manager,onshape.product.product manager,model_onshape_product_product,group_onshape_manager,1,1,1,1 +access_onshape_mrp_bom_user,onshape.mrp.bom user,model_onshape_mrp_bom,group_onshape_user,1,0,0,0 +access_onshape_mrp_bom_manager,onshape.mrp.bom manager,model_onshape_mrp_bom,group_onshape_manager,1,1,1,1 +access_onshape_import_wizard_manager,onshape.import.wizard manager,model_onshape_import_wizard,group_onshape_manager,1,1,1,1 diff --git a/connector_onshape/security/onshape_security.xml b/connector_onshape/security/onshape_security.xml new file mode 100644 index 000000000..ba21ac48d --- /dev/null +++ b/connector_onshape/security/onshape_security.xml @@ -0,0 +1,21 @@ + + + + + Onshape + 100 + + + + User + + + + + + Manager + + + + + diff --git a/connector_onshape/static/description/icon.png b/connector_onshape/static/description/icon.png new file mode 100644 index 000000000..33cbd38af 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..c5facbd38 --- /dev/null +++ b/connector_onshape/static/description/index.html @@ -0,0 +1,610 @@ + + + + + +Onshape Connector + + + +
+

Onshape Connector

+ + +

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

+

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

+

It synchronizes:

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

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

+

Table of contents

+ +
+

Configuration

+
+

Onshape Developer Portal Setup

+

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

+

HMAC API Keys (quickest to start):

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

Finding your Team / Company ID:

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

OAuth2 App Store (recommended for production):

+

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

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

Odoo Backend Configuration

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

Import Settings

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

Webhooks (Real-Time Sync)

+

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

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

Scheduled Sync (Cron Jobs)

+

Three cron jobs are created (disabled by default):

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

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

+
+
+
+

Usage

+
+

Import Documents

+

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

+
+
+

Import Products

+

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

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

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

+
+
+

Import BOMs

+

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

+
+
+

Export Part Numbers

+

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

+
+
+

Automatic Export

+

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

+
+
+

Import Wizard

+

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

+
+
+
+

Bug Tracker

+

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

+

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

+
+
+

Credits

+
+

Authors

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

Contributors

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

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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

+

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

+

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

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

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

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + onshape.document.tree + onshape.document + + + + + + + + + + + + + + + onshape.document.search + onshape.document + + + + + + + + + + + + + + + + + + + Onshape Documents + onshape.document + tree,form + + + + + +
diff --git a/connector_onshape/views/onshape_product_views.xml b/connector_onshape/views/onshape_product_views.xml new file mode 100644 index 000000000..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..75322de75 --- /dev/null +++ b/connector_onshape/wizards/__init__.py @@ -0,0 +1 @@ +from . import onshape_import_wizard diff --git a/connector_onshape/wizards/onshape_import_wizard.py b/connector_onshape/wizards/onshape_import_wizard.py new file mode 100644 index 000000000..7f7341dd4 --- /dev/null +++ b/connector_onshape/wizards/onshape_import_wizard.py @@ -0,0 +1,78 @@ +# Copyright 2024 Kencove Farm Fence Supplies +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class OnshapeImportWizard(models.TransientModel): + _name = "onshape.import.wizard" + _description = "Onshape Import Wizard" + + backend_id = fields.Many2one( + "onshape.backend", + string="Backend", + required=True, + default=lambda self: self._default_backend_id(), + ) + import_type = fields.Selection( + [ + ("documents", "Documents Only"), + ("products", "Documents + Products"), + ("boms", "Documents + Products + BOMs"), + ("full", "Full Sync (All)"), + ], + required=True, + default="products", + ) + auto_create_products = fields.Boolean( + string="Auto-create Products", + help="Create new Odoo products for unmatched Onshape parts.", + ) + + @api.model + def _default_backend_id(self): + return self.env["onshape.backend"].search([("state", "=", "active")], limit=1) + + def action_import(self): + self.ensure_one() + backend = self.backend_id + if backend.state != "active": + raise UserError(_("Backend must be active. Check credentials first.")) + + 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/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, +)