diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce309cc1e5..1ca69f5ca9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added `order_queryset` report helper function in [#10439](https://github.com/inventree/InvenTree/pull/10439)
+- Added `SupplierMixin` to import data from suppliers in [#9761](https://github.com/inventree/InvenTree/pull/9761)
- Added much more detailed status information for machines to the API endpoint (including backend and frontend changes) in [#10381](https://github.com/inventree/InvenTree/pull/10381)
- Added ability to partially complete and partially scrap build outputs in [#10499](https://github.com/inventree/InvenTree/pull/10499)
- Added support for Redis ACL user-based authentication in [#10551](https://github.com/inventree/InvenTree/pull/10551)
diff --git a/docs/docs/assets/images/part/import_part.png b/docs/docs/assets/images/part/import_part.png
new file mode 100644
index 0000000000..94bdb55601
Binary files /dev/null and b/docs/docs/assets/images/part/import_part.png differ
diff --git a/docs/docs/assets/images/part/import_part_wizard.png b/docs/docs/assets/images/part/import_part_wizard.png
new file mode 100644
index 0000000000..e20e16b1b5
Binary files /dev/null and b/docs/docs/assets/images/part/import_part_wizard.png differ
diff --git a/docs/docs/assets/images/part/import_supplier_part.png b/docs/docs/assets/images/part/import_supplier_part.png
new file mode 100644
index 0000000000..2d9c7bb833
Binary files /dev/null and b/docs/docs/assets/images/part/import_supplier_part.png differ
diff --git a/docs/docs/part/import.md b/docs/docs/part/import.md
new file mode 100644
index 0000000000..a8a6626d0c
--- /dev/null
+++ b/docs/docs/part/import.md
@@ -0,0 +1,28 @@
+---
+title: Importing Data from suppliers
+---
+
+## Import data from suppliers
+
+InvenTree can integrate with external suppliers and import data from them, which helps to setup your system. Currently parts, supplier parts and manufacturer parts can be created automatically.
+
+### Requirements
+
+1. Install a supplier mixin plugin for you supplier
+2. Goto "Admin Center > Plugins > [The supplier plugin]" and set the supplier company setting. Some plugins may require additional settings like API tokens.
+
+### Import a part
+
+New parts can be imported from the _Part Category_ view, by pressing the _Import Part_ button:
+
+{{ image("part/import_part.png", "Import part") }}
+
+Then just follow the wizard to confirm the category, select the parameters and create initial stock.
+
+{{ image("part/import_part_wizard.png", "Import part wizard") }}
+
+### Import a supplier part
+
+If you already have the part created, you can also just import the supplier part with it's corresponding manufacturer part. Open the supplier panel for the part and use the "Import supplier part" button:
+
+{{ image("part/import_supplier_part.png", "Import supplier part") }}
diff --git a/docs/docs/plugins/mixins/supplier.md b/docs/docs/plugins/mixins/supplier.md
new file mode 100644
index 0000000000..9c7071033f
--- /dev/null
+++ b/docs/docs/plugins/mixins/supplier.md
@@ -0,0 +1,48 @@
+---
+title: Supplier Mixin
+---
+
+## SupplierMixin
+
+The `SupplierMixin` class enables plugins to integrate with external suppliers, enabling seamless creation of parts, supplier parts, and manufacturer parts with just a few clicks from the supplier. The import process is split into multiple phases:
+
+- Search supplier
+- Select InvenTree category
+- Match Part Parameters
+- Create initial Stock
+
+### Import Methods
+
+A plugin can connect to multiple suppliers. The `get_suppliers` method should return a list of available supplier connections (e.g. using different credentials).
+When a user initiates a search through the UI, the `get_search_results` function is called with the search term, supplier slug returned previously, and the search results are returned. These contain a `part_id` which is then passed to `get_import_data` along with the `supplier_slug`, if a user decides to import that specific part. This function should return a bunch of data that is needed for the import process. This data may be cached in the future for the same `part_id`. Then depending if the user only wants to import the supplier and manufacturer part or the whole part, the `import_part`, `import_manufacturer_part` and `import_supplier_part` methods are called automatically. If the user has imported the complete part, the `get_parameters` method is used to get a list of parameters which then can be match to inventree part parameter templates with some provided guidance. Additionally the `get_pricing_data` method is used to extract price breaks which are automatically considered when creating initial stock through the UI in the part import wizard.
+
+For that to work, a few methods need to be overridden:
+
+::: plugin.base.supplier.mixins.SupplierMixin
+ options:
+ show_bases: False
+ show_root_heading: False
+ show_root_toc_entry: False
+ summary: False
+ members:
+ - get_search_results
+ - get_import_data
+ - get_pricing_data
+ - get_parameters
+ - import_part
+ - import_manufacturer_part
+ - import_supplier_part
+ extra:
+ show_sources: True
+
+### Sample Plugin
+
+A simple example is provided in the InvenTree code base. Note that this uses some static data, but this can be extended in a real world plugin to e.g. call the supplier's API:
+
+::: plugin.samples.supplier.supplier_sample.SampleSupplierPlugin
+ options:
+ show_bases: False
+ show_root_heading: False
+ show_root_toc_entry: False
+ show_source: True
+ members: []
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 2639207c87..ec95a3692b 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -146,6 +146,7 @@ nav:
- Parts:
- Parts: part/index.md
- Creating Parts: part/create.md
+ - Importing Parts: part/import.md
- Virtual Parts: part/virtual.md
- Part Views: part/views.md
- Tracking: part/trackable.md
@@ -237,6 +238,7 @@ nav:
- Report Mixin: plugins/mixins/report.md
- Schedule Mixin: plugins/mixins/schedule.md
- Settings Mixin: plugins/mixins/settings.md
+ - Supplier Mixin: plugins/mixins/supplier.md
- Transition Mixin: plugins/mixins/transition.md
- URL Mixin: plugins/mixins/urls.md
- User Interface Mixin: plugins/mixins/ui.md
diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py
index 5f3eca035f..67309e5e10 100644
--- a/src/backend/InvenTree/InvenTree/api.py
+++ b/src/backend/InvenTree/InvenTree/api.py
@@ -1,5 +1,6 @@
"""Main JSON interface views."""
+import collections
import json
from pathlib import Path
@@ -488,16 +489,46 @@ class BulkCreateMixin:
if isinstance(data, list):
created_items = []
+ errors = []
+ has_errors = False
# If data is a list, we assume it is a bulk create request
if len(data) == 0:
raise ValidationError({'non_field_errors': _('No data provided')})
- for item in data:
- serializer = self.get_serializer(data=item)
- serializer.is_valid(raise_exception=True)
- self.perform_create(serializer)
- created_items.append(serializer.data)
+ # validate unique together fields
+ if unique_create_fields := getattr(self, 'unique_create_fields', None):
+ existing = collections.defaultdict(list)
+ for idx, item in enumerate(data):
+ key = tuple(item[v] for v in unique_create_fields)
+ existing[key].append(idx)
+
+ unique_errors = [[] for _ in range(len(data))]
+ has_unique_errors = False
+ for item in existing.values():
+ if len(item) > 1:
+ has_unique_errors = True
+ error = {}
+ for field_name in unique_create_fields:
+ error[field_name] = [_('This field must be unique.')]
+ for idx in item:
+ unique_errors[idx] = error
+ if has_unique_errors:
+ raise ValidationError(unique_errors)
+
+ with transaction.atomic():
+ for item in data:
+ serializer = self.get_serializer(data=item)
+ if serializer.is_valid():
+ self.perform_create(serializer)
+ created_items.append(serializer.data)
+ errors.append([])
+ else:
+ errors.append(serializer.errors)
+ has_errors = True
+
+ if has_errors:
+ raise ValidationError(errors)
return Response(created_items, status=201)
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index ef1ad9482a..1fed551bdf 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,12 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 409
+INVENTREE_API_VERSION = 410
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v410 -> 2025-06-12 : https://github.com/inventree/InvenTree/pull/9761
+ - Add supplier search and import API endpoints
+ - Add part parameter bulk create API endpoint
+
v409 -> 2025-10-17 : https://github.com/inventree/InvenTree/pull/10601
- Adds ability to filter StockList API by manufacturer part ID
diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py
index 9f66d47796..bf7814a08d 100644
--- a/src/backend/InvenTree/part/api.py
+++ b/src/backend/InvenTree/part/api.py
@@ -17,6 +17,7 @@ from rest_framework.response import Response
import part.filters
from data_exporter.mixins import DataExportViewMixin
from InvenTree.api import (
+ BulkCreateMixin,
BulkDeleteMixin,
BulkUpdateMixin,
ListCreateDestroyAPIView,
@@ -1416,7 +1417,11 @@ class PartParameterFilter(FilterSet):
class PartParameterList(
- PartParameterAPIMixin, OutputOptionsMixin, DataExportViewMixin, ListCreateAPI
+ BulkCreateMixin,
+ PartParameterAPIMixin,
+ OutputOptionsMixin,
+ DataExportViewMixin,
+ ListCreateAPI,
):
"""API endpoint for accessing a list of PartParameter objects.
@@ -1444,6 +1449,8 @@ class PartParameterList(
'template__units',
]
+ unique_create_fields = ['part', 'template']
+
class PartParameterDetail(
PartParameterAPIMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
diff --git a/src/backend/InvenTree/part/test_param.py b/src/backend/InvenTree/part/test_param.py
index af2c162d53..2891f5c839 100644
--- a/src/backend/InvenTree/part/test_param.py
+++ b/src/backend/InvenTree/part/test_param.py
@@ -364,6 +364,36 @@ class PartParameterTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), 8)
+ def test_bulk_create_params(self):
+ """Test that we can bulk create parameters via the API."""
+ url = reverse('api-part-parameter-list')
+ part4 = Part.objects.get(pk=4)
+
+ data = [
+ {'part': 4, 'template': 1, 'data': 70},
+ {'part': 4, 'template': 2, 'data': 80},
+ {'part': 4, 'template': 1, 'data': 80},
+ ]
+
+ # test that having non unique part/template combinations fails
+ res = self.post(url, data, expected_code=400)
+ self.assertEqual(len(res.data), 3)
+ self.assertEqual(len(res.data[1]), 0)
+ for err in [res.data[0], res.data[2]]:
+ self.assertEqual(len(err), 2)
+ self.assertEqual(str(err['part'][0]), 'This field must be unique.')
+ self.assertEqual(str(err['template'][0]), 'This field must be unique.')
+ self.assertEqual(PartParameter.objects.filter(part=part4).count(), 0)
+
+ # Now, create a valid set of parameters
+ data = [
+ {'part': 4, 'template': 1, 'data': 70},
+ {'part': 4, 'template': 2, 'data': 80},
+ ]
+ res = self.post(url, data, expected_code=201)
+ self.assertEqual(len(res.data), 2)
+ self.assertEqual(PartParameter.objects.filter(part=part4).count(), 2)
+
def test_param_detail(self):
"""Tests for the PartParameter detail endpoint."""
url = reverse('api-part-parameter-detail', kwargs={'pk': 5})
diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py
index 3e62ff606f..8585a37275 100644
--- a/src/backend/InvenTree/plugin/api.py
+++ b/src/backend/InvenTree/plugin/api.py
@@ -31,6 +31,7 @@ from InvenTree.mixins import (
from plugin.base.action.api import ActionPluginView
from plugin.base.barcodes.api import barcode_api_urls
from plugin.base.locate.api import LocatePluginView
+from plugin.base.supplier.api import supplier_api_urls
from plugin.base.ui.api import ui_plugins_api_urls
from plugin.models import PluginConfig, PluginSetting, PluginUserSetting
from plugin.plugin import InvenTreePlugin
@@ -601,4 +602,5 @@ plugin_api_urls = [
path('', PluginList.as_view(), name='api-plugin-list'),
]),
),
+ path('supplier/', include(supplier_api_urls)),
]
diff --git a/src/backend/InvenTree/plugin/base/supplier/__init__.py b/src/backend/InvenTree/plugin/base/supplier/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/backend/InvenTree/plugin/base/supplier/api.py b/src/backend/InvenTree/plugin/base/supplier/api.py
new file mode 100644
index 0000000000..ec96e21e04
--- /dev/null
+++ b/src/backend/InvenTree/plugin/base/supplier/api.py
@@ -0,0 +1,246 @@
+"""API views for supplier plugins in InvenTree."""
+
+from typing import TYPE_CHECKING
+
+from django.db import transaction
+from django.urls import path
+
+from drf_spectacular.utils import OpenApiParameter, extend_schema
+from rest_framework import status
+from rest_framework.exceptions import NotFound
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from InvenTree import permissions
+from part.models import PartCategoryParameterTemplate
+from plugin import registry
+from plugin.plugin import PluginMixinEnum
+
+from .serializers import (
+ ImportRequestSerializer,
+ ImportResultSerializer,
+ SearchResultSerializer,
+ SupplierListSerializer,
+)
+
+if TYPE_CHECKING:
+ from plugin.base.supplier.mixins import SupplierMixin
+else: # pragma: no cover
+
+ class SupplierMixin:
+ """Dummy class for type checking."""
+
+
+def get_supplier_plugin(plugin_slug: str, supplier_slug: str) -> SupplierMixin:
+ """Return the supplier plugin for the given plugin and supplier slugs."""
+ supplier_plugin = None
+ for plugin in registry.with_mixin(PluginMixinEnum.SUPPLIER):
+ if plugin.slug == plugin_slug:
+ supplier_plugin = plugin
+ break
+
+ if not supplier_plugin:
+ raise NotFound(detail=f"Plugin '{plugin_slug}' not found")
+
+ if not any(s.slug == supplier_slug for s in supplier_plugin.get_suppliers()):
+ raise NotFound(
+ detail=f"Supplier '{supplier_slug}' not found for plugin '{plugin_slug}'"
+ )
+
+ return supplier_plugin
+
+
+class ListSupplier(APIView):
+ """List all available supplier plugins.
+
+ - GET: List supplier plugins
+ """
+
+ role_required = 'part.add'
+ permission_classes = [
+ permissions.IsAuthenticatedOrReadScope,
+ permissions.RolePermission,
+ ]
+ serializer_class = SupplierListSerializer
+
+ @extend_schema(responses={200: SupplierListSerializer(many=True)})
+ def get(self, request):
+ """List all available supplier plugins."""
+ suppliers = []
+ for plugin in registry.with_mixin(PluginMixinEnum.SUPPLIER):
+ suppliers.extend([
+ {
+ 'plugin_slug': plugin.slug,
+ 'supplier_slug': supplier.slug,
+ 'supplier_name': supplier.name,
+ }
+ for supplier in plugin.get_suppliers()
+ ])
+
+ return Response(suppliers)
+
+
+class SearchPart(APIView):
+ """Search parts by supplier.
+
+ - GET: Start part search
+ """
+
+ role_required = 'part.add'
+ permission_classes = [
+ permissions.IsAuthenticatedOrReadScope,
+ permissions.RolePermission,
+ ]
+ serializer_class = SearchResultSerializer
+
+ @extend_schema(
+ parameters=[
+ OpenApiParameter(name='plugin', description='Plugin slug', required=True),
+ OpenApiParameter(
+ name='supplier', description='Supplier slug', required=True
+ ),
+ OpenApiParameter(name='term', description='Search term', required=True),
+ ],
+ responses={200: SearchResultSerializer(many=True)},
+ )
+ def get(self, request):
+ """Search parts by supplier."""
+ plugin_slug = request.query_params.get('plugin', '')
+ supplier_slug = request.query_params.get('supplier', '')
+ term = request.query_params.get('term', '')
+
+ supplier_plugin = get_supplier_plugin(plugin_slug, supplier_slug)
+ try:
+ results = supplier_plugin.get_search_results(supplier_slug, term)
+ except Exception as e:
+ return Response(
+ {'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+ response = SearchResultSerializer(results, many=True).data
+ return Response(response)
+
+
+class ImportPart(APIView):
+ """Import a part by supplier.
+
+ - POST: Attempt to import part by sku
+ """
+
+ role_required = 'part.add'
+ permission_classes = [
+ permissions.IsAuthenticatedOrReadScope,
+ permissions.RolePermission,
+ ]
+ serializer_class = ImportResultSerializer
+
+ @extend_schema(
+ request=ImportRequestSerializer, responses={200: ImportResultSerializer}
+ )
+ def post(self, request):
+ """Import a part by supplier."""
+ serializer = ImportRequestSerializer(data=request.data)
+ if not serializer.is_valid():
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+ # Extract validated data
+ plugin_slug = serializer.validated_data.get('plugin', '')
+ supplier_slug = serializer.validated_data.get('supplier', '')
+ part_import_id = serializer.validated_data.get('part_import_id', '')
+ category = serializer.validated_data.get('category_id', None)
+ part = serializer.validated_data.get('part_id', None)
+
+ supplier_plugin = get_supplier_plugin(plugin_slug, supplier_slug)
+
+ # Validate part/category
+ if not part and not category:
+ return Response(
+ {
+ 'detail': "'category_id' is not provided, but required if no part_id is provided"
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ from plugin.base.supplier.mixins import supplier
+
+ # Import part data
+ try:
+ import_data = supplier_plugin.get_import_data(supplier_slug, part_import_id)
+
+ with transaction.atomic():
+ # create part if it does not exist
+ if not part:
+ part = supplier_plugin.import_part(
+ import_data, category=category, creation_user=request.user
+ )
+
+ # create manufacturer part
+ manufacturer_part = supplier_plugin.import_manufacturer_part(
+ import_data, part=part
+ )
+
+ # create supplier part
+ supplier_part = supplier_plugin.import_supplier_part(
+ import_data, part=part, manufacturer_part=manufacturer_part
+ )
+
+ # set default supplier if not set
+ if not part.default_supplier:
+ part.default_supplier = supplier_part
+ part.save()
+
+ # get pricing
+ pricing = supplier_plugin.get_pricing_data(import_data)
+
+ # get parameters
+ parameters = supplier_plugin.get_parameters(import_data)
+ except supplier.PartNotFoundError:
+ return Response(
+ {'detail': f"Part with id: '{part_import_id}' not found"},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+ except Exception as e:
+ return Response(
+ {'detail': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+ # add default parameters for category
+ if category:
+ categories = category.get_ancestors(include_self=True)
+ category_parameters = PartCategoryParameterTemplate.objects.filter(
+ category__in=categories
+ )
+
+ for c in category_parameters:
+ for p in parameters:
+ if p.parameter_template == c.parameter_template:
+ p.on_category = True
+ p.value = p.value if p.value is not None else c.default_value
+ break
+ else:
+ parameters.append(
+ supplier.ImportParameter(
+ name=c.parameter_template.name,
+ value=c.default_value,
+ on_category=True,
+ parameter_template=c.parameter_template,
+ )
+ )
+ parameters.sort(key=lambda x: x.on_category, reverse=True)
+
+ response = ImportResultSerializer({
+ 'part_id': part.pk,
+ 'part_detail': part,
+ 'supplier_part_id': supplier_part.pk,
+ 'manufacturer_part_id': manufacturer_part.pk,
+ 'pricing': pricing,
+ 'parameters': parameters,
+ }).data
+ return Response(response)
+
+
+supplier_api_urls = [
+ path('list/', ListSupplier.as_view(), name='api-supplier-list'),
+ path('search/', SearchPart.as_view(), name='api-supplier-search'),
+ path('import/', ImportPart.as_view(), name='api-supplier-import'),
+]
diff --git a/src/backend/InvenTree/plugin/base/supplier/helpers.py b/src/backend/InvenTree/plugin/base/supplier/helpers.py
new file mode 100644
index 0000000000..da4828fd91
--- /dev/null
+++ b/src/backend/InvenTree/plugin/base/supplier/helpers.py
@@ -0,0 +1,88 @@
+"""Dataclasses for supplier plugins."""
+
+from dataclasses import dataclass
+from typing import Optional
+
+import part.models as part_models
+
+
+@dataclass
+class Supplier:
+ """Data class to represent a supplier.
+
+ Note that one plugin can connect to multiple suppliers this way with e.g. different credentials.
+
+ Attributes:
+ slug (str): A unique identifier for the supplier.
+ name (str): The human-readable name of the supplier.
+ """
+
+ slug: str
+ name: str
+
+
+@dataclass
+class SearchResult:
+ """Data class to represent a search result from a supplier.
+
+ Attributes:
+ sku (str): The stock keeping unit identifier for the part.
+ name (str): The name of the part.
+ exact (bool): Indicates if the search result is an exact match.
+ description (Optional[str]): A brief description of the part.
+ price (Optional[str]): The price of the part as a string.
+ link (Optional[str]): A URL link to the part on the supplier's website.
+ image_url (Optional[str]): A URL to an image of the part.
+ id (Optional[str]): An optional identifier for the part (part_id), defaults to sku if not provided
+ existing_part (Optional[part_models.Part]): An existing part in the system that matches this search result, if any.
+ """
+
+ sku: str
+ name: str
+ exact: bool
+ description: Optional[str] = None
+ price: Optional[str] = None
+ link: Optional[str] = None
+ image_url: Optional[str] = None
+ id: Optional[str] = None
+ existing_part: Optional[part_models.Part] = None
+
+ def __post_init__(self):
+ """Post-initialization to set the ID if not provided."""
+ if not self.id:
+ self.id = self.sku
+
+
+@dataclass
+class ImportParameter:
+ """Data class to represent a parameter for a part during import.
+
+ Attributes:
+ name (str): The name of the parameter.
+ value (str): The value of the parameter.
+ on_category (Optional[bool]): Indicates if the parameter is associated with a category. This will be automatically set by InvenTree
+ parameter_template (Optional[PartParameterTemplate]): The associated parameter template, if any.
+ """
+
+ name: str
+ value: str
+ on_category: Optional[bool] = False
+ parameter_template: Optional[part_models.PartParameterTemplate] = None
+
+ def __post_init__(self):
+ """Post-initialization to fetch the parameter template if not provided."""
+ if not self.parameter_template:
+ try:
+ self.parameter_template = part_models.PartParameterTemplate.objects.get(
+ name__iexact=self.name
+ )
+ except part_models.PartParameterTemplate.DoesNotExist:
+ pass
+
+
+class PartNotFoundError(Exception):
+ """Exception raised when a part is not found during import."""
+
+
+class PartImportError(Exception):
+ """Exception raised when an error occurs during part import."""
diff --git a/src/backend/InvenTree/plugin/base/supplier/mixins.py b/src/backend/InvenTree/plugin/base/supplier/mixins.py
new file mode 100644
index 0000000000..6542989a64
--- /dev/null
+++ b/src/backend/InvenTree/plugin/base/supplier/mixins.py
@@ -0,0 +1,177 @@
+"""Plugin mixin class for Supplier Integration."""
+
+import io
+from typing import Any, Generic, Optional, TypeVar
+
+import django.contrib.auth.models
+from django.core.exceptions import ValidationError
+from django.core.files.base import ContentFile
+
+import company.models
+import part.models as part_models
+from InvenTree.helpers_model import download_image_from_url
+from plugin import PluginMixinEnum
+from plugin.base.supplier import helpers as supplier
+from plugin.mixins import SettingsMixin
+
+PartData = TypeVar('PartData')
+
+
+class SupplierMixin(SettingsMixin, Generic[PartData]):
+ """Mixin which provides integration to specific suppliers."""
+
+ class MixinMeta:
+ """Meta options for this mixin."""
+
+ MIXIN_NAME = 'Supplier'
+
+ def __init__(self):
+ """Register mixin."""
+ super().__init__()
+ self.add_mixin(PluginMixinEnum.SUPPLIER, True, __class__)
+
+ self.SETTINGS['SUPPLIER'] = {
+ 'name': 'Supplier',
+ 'description': 'The Supplier which this plugin integrates with.',
+ 'model': 'company.company',
+ 'model_filters': {'is_supplier': True},
+ 'required': True,
+ }
+
+ @property
+ def supplier_company(self):
+ """Return the supplier company object."""
+ pk = self.get_setting('SUPPLIER', cache=True)
+ if not pk:
+ raise supplier.PartImportError('Supplier setting is missing.')
+
+ return company.models.Company.objects.get(pk=pk)
+
+ # --- Methods to be overridden by plugins ---
+ def get_suppliers(self) -> list[supplier.Supplier]:
+ """Return a list of available suppliers."""
+ raise NotImplementedError('This method needs to be overridden.')
+
+ def get_search_results(
+ self, supplier_slug: str, term: str
+ ) -> list[supplier.SearchResult]:
+ """Return a list of search results for the given search term."""
+ raise NotImplementedError('This method needs to be overridden.')
+
+ def get_import_data(self, supplier_slug: str, part_id: str) -> PartData:
+ """Return the import data for the given part ID."""
+ raise NotImplementedError('This method needs to be overridden.')
+
+ def get_pricing_data(self, data: PartData) -> dict[int, tuple[float, str]]:
+ """Return a dictionary of pricing data for the given part data."""
+ raise NotImplementedError('This method needs to be overridden.')
+
+ def get_parameters(self, data: PartData) -> list[supplier.ImportParameter]:
+ """Return a list of parameters for the given part data."""
+ raise NotImplementedError('This method needs to be overridden.')
+
+ def import_part(
+ self,
+ data: PartData,
+ *,
+ category: Optional[part_models.PartCategory],
+ creation_user: Optional[django.contrib.auth.models.User],
+ ) -> part_models.Part:
+ """Import a part using the provided data.
+
+ This may include:
+ - Creating a new part
+ - Add an image to the part
+ - if this part has several variants, (create) a template part and assign it to the part
+ - create related parts
+ - add attachments to the part
+ """
+ raise NotImplementedError('This method needs to be overridden.')
+
+ def import_manufacturer_part(
+ self, data: PartData, *, part: part_models.Part
+ ) -> company.models.ManufacturerPart:
+ """Import a manufacturer part using the provided data.
+
+ This may include:
+ - Creating a new manufacturer
+ - Creating a new manufacturer part
+ - Assigning the part to the manufacturer part
+ - Setting the default supplier for the part
+ - Adding parameters to the manufacturer part
+ - Adding attachments to the manufacturer part
+ """
+ raise NotImplementedError('This method needs to be overridden.')
+
+ def import_supplier_part(
+ self,
+ data: PartData,
+ *,
+ part: part_models.Part,
+ manufacturer_part: company.models.ManufacturerPart,
+ ) -> part_models.SupplierPart:
+ """Import a supplier part using the provided data.
+
+ This may include:
+ - Creating a new supplier part
+ - Creating supplier price breaks
+ """
+ raise NotImplementedError('This method needs to be overridden.')
+
+ # --- Helper methods for importing parts ---
+ def download_image(self, img_url: str):
+ """Download an image from the given URL and return it as a ContentFile."""
+ img_r = download_image_from_url(img_url)
+ fmt = img_r.format or 'PNG'
+ buffer = io.BytesIO()
+ img_r.save(buffer, format=fmt)
+
+ return ContentFile(buffer.getvalue()), fmt
+
+ def get_template_part(
+ self, other_variants: list[part_models.Part], template_kwargs: dict[str, Any]
+ ) -> part_models.Part:
+ """Helper function to handle variant parts.
+
+ This helper function identifies all roots for the provided 'other_variants' list
+ - for no root => root part will be created using the 'template_kwargs'
+ - for one root
+ - root is a template => return it
+ - root is no template, create a new template like if there is no root
+ and assign it to only root that was found and return it
+ - for multiple roots => error raised
+ """
+ root_set = {v.get_root() for v in other_variants}
+
+ # check how much roots for the variant parts exist to identify the parent_part
+ parent_part = None # part that should be used as parent_part
+ root_part = None # part that was discovered as root part in root_set
+ if len(root_set) == 1:
+ root_part = next(iter(root_set))
+ if root_part.is_template:
+ parent_part = root_part
+
+ if len(root_set) == 0 or (root_part and not root_part.is_template):
+ parent_part = part_models.Part.objects.create(**template_kwargs)
+
+ if not parent_part:
+ raise supplier.PartImportError(
+ f'A few variant parts from the supplier are already imported, but have different InvenTree variant root parts, try to merge them to the same root variant template part (parts: {", ".join(str(p.pk) for p in other_variants)}).'
+ )
+
+ # assign parent_part to root_part if root_part has no variant of already
+ if root_part and not root_part.is_template and not root_part.variant_of:
+ root_part.variant_of = parent_part # type: ignore
+ root_part.save()
+
+ return parent_part
+
+ def create_related_parts(
+ self, part: part_models.Part, related_parts: list[part_models.Part]
+ ):
+ """Create relationships between the given part and related parts."""
+ for p in related_parts:
+ try:
+ part_models.PartRelated.objects.create(part_1=part, part_2=p)
+ except ValidationError:
+ pass # pass, duplicate relationship detected
diff --git a/src/backend/InvenTree/plugin/base/supplier/serializers.py b/src/backend/InvenTree/plugin/base/supplier/serializers.py
new file mode 100644
index 0000000000..75f728c1f9
--- /dev/null
+++ b/src/backend/InvenTree/plugin/base/supplier/serializers.py
@@ -0,0 +1,115 @@
+"""Serializer definitions for the supplier plugin base."""
+
+from typing import Any, Optional
+
+from rest_framework import serializers
+
+import part.models as part_models
+from part.serializers import PartSerializer
+
+
+class SupplierListSerializer(serializers.Serializer):
+ """Serializer for a supplier plugin."""
+
+ plugin_slug = serializers.CharField()
+ supplier_slug = serializers.CharField()
+ supplier_name = serializers.CharField()
+
+
+class SearchResultSerializer(serializers.Serializer):
+ """Serializer for a search result."""
+
+ class Meta:
+ """Meta options for the SearchResultSerializer."""
+
+ fields = [
+ 'id',
+ 'sku',
+ 'name',
+ 'exact',
+ 'description',
+ 'price',
+ 'link',
+ 'image_url',
+ 'existing_part_id',
+ ]
+ read_only_fields = fields
+
+ id = serializers.CharField()
+ sku = serializers.CharField()
+ name = serializers.CharField()
+ exact = serializers.BooleanField()
+ description = serializers.CharField()
+ price = serializers.CharField()
+ link = serializers.CharField()
+ image_url = serializers.CharField()
+ existing_part_id = serializers.SerializerMethodField()
+
+ def get_existing_part_id(self, value) -> Optional[int]:
+ """Return the ID of the existing part if available."""
+ return getattr(value.existing_part, 'pk', None)
+
+
+class ImportParameterSerializer(serializers.Serializer):
+ """Serializer for a ImportParameter."""
+
+ class Meta:
+ """Meta options for the ImportParameterSerializer."""
+
+ fields = ['name', 'value', 'parameter_template', 'on_category']
+
+ name = serializers.CharField()
+ value = serializers.CharField()
+ parameter_template = serializers.SerializerMethodField()
+ on_category = serializers.BooleanField()
+
+ def get_parameter_template(self, value) -> Optional[int]:
+ """Return the ID of the parameter template if available."""
+ return getattr(value.parameter_template, 'pk', None)
+
+
+class ImportRequestSerializer(serializers.Serializer):
+ """Serializer for the import request."""
+
+ plugin = serializers.CharField(required=True)
+ supplier = serializers.CharField(required=True)
+ part_import_id = serializers.CharField(required=True)
+ category_id = serializers.PrimaryKeyRelatedField(
+ queryset=part_models.PartCategory.objects.all(),
+ many=False,
+ required=False,
+ allow_null=True,
+ )
+ part_id = serializers.PrimaryKeyRelatedField(
+ queryset=part_models.Part.objects.all(),
+ many=False,
+ required=False,
+ allow_null=True,
+ )
+
+
+class ImportResultSerializer(serializers.Serializer):
+ """Serializer for the import result."""
+
+ class Meta:
+ """Meta options for the ImportResultSerializer."""
+
+ fields = [
+ 'part_id',
+ 'part_detail',
+ 'manufacturer_part_id',
+ 'supplier_part_id',
+ 'pricing',
+ 'parameters',
+ ]
+
+ part_id = serializers.IntegerField()
+ part_detail = PartSerializer()
+ manufacturer_part_id = serializers.IntegerField()
+ supplier_part_id = serializers.IntegerField()
+ pricing = serializers.SerializerMethodField()
+ parameters = ImportParameterSerializer(many=True)
+
+ def get_pricing(self, value: Any) -> list[tuple[float, str]]:
+ """Return the pricing data as a dictionary."""
+ return value['pricing']
diff --git a/src/backend/InvenTree/plugin/mixins/__init__.py b/src/backend/InvenTree/plugin/mixins/__init__.py
index 0cd713294a..565e940bb9 100644
--- a/src/backend/InvenTree/plugin/mixins/__init__.py
+++ b/src/backend/InvenTree/plugin/mixins/__init__.py
@@ -20,6 +20,8 @@ from plugin.base.integration.ValidationMixin import ValidationMixin
from plugin.base.label.mixins import LabelPrintingMixin
from plugin.base.locate.mixins import LocateMixin
from plugin.base.mail.mixins import MailMixin
+from plugin.base.supplier import helpers as supplier
+from plugin.base.supplier.mixins import SupplierMixin
from plugin.base.ui.mixins import UserInterfaceMixin
__all__ = [
@@ -41,8 +43,10 @@ __all__ = [
'ScheduleMixin',
'SettingsMixin',
'SupplierBarcodeMixin',
+ 'SupplierMixin',
'TransitionMixin',
'UrlsMixin',
'UserInterfaceMixin',
'ValidationMixin',
+ 'supplier',
]
diff --git a/src/backend/InvenTree/plugin/plugin.py b/src/backend/InvenTree/plugin/plugin.py
index ec882fd691..10e9a698e4 100644
--- a/src/backend/InvenTree/plugin/plugin.py
+++ b/src/backend/InvenTree/plugin/plugin.py
@@ -75,6 +75,7 @@ class PluginMixinEnum(StringEnum):
SCHEDULE = 'schedule'
SETTINGS = 'settings'
SETTINGS_CONTENT = 'settingscontent'
+ SUPPLIER = 'supplier'
STATE_TRANSITION = 'statetransition'
SUPPLIER_BARCODE = 'supplier-barcode'
URLS = 'urls'
diff --git a/src/backend/InvenTree/plugin/samples/supplier/__init__.py b/src/backend/InvenTree/plugin/samples/supplier/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/backend/InvenTree/plugin/samples/supplier/supplier_sample.py b/src/backend/InvenTree/plugin/samples/supplier/supplier_sample.py
new file mode 100644
index 0000000000..9fe6d8ea00
--- /dev/null
+++ b/src/backend/InvenTree/plugin/samples/supplier/supplier_sample.py
@@ -0,0 +1,182 @@
+"""Sample supplier plugin."""
+
+from company.models import Company, ManufacturerPart, SupplierPart, SupplierPriceBreak
+from part.models import Part
+from plugin.mixins import SupplierMixin, supplier
+from plugin.plugin import InvenTreePlugin
+
+
+class SampleSupplierPlugin(SupplierMixin, InvenTreePlugin):
+ """Example plugin to integrate with a dummy supplier."""
+
+ NAME = 'SampleSupplierPlugin'
+ SLUG = 'samplesupplier'
+ TITLE = 'My sample supplier plugin'
+
+ VERSION = '0.0.1'
+
+ def __init__(self):
+ """Initialize the sample supplier plugin."""
+ super().__init__()
+
+ self.sample_data = []
+ for material in ['Steel', 'Aluminium', 'Brass']:
+ for size in ['M1', 'M2', 'M3', 'M4', 'M5']:
+ for length in range(5, 30, 5):
+ self.sample_data.append({
+ 'material': material,
+ 'thread': size,
+ 'length': length,
+ 'sku': f'BOLT-{material}-{size}-{length}',
+ 'name': f'Bolt {size}x{length}mm {material}',
+ 'description': f'This is a sample part description demonstration purposes for the {size}x{length} {material} bolt.',
+ 'price': {
+ 1: [1.0, 'EUR'],
+ 10: [0.9, 'EUR'],
+ 100: [0.8, 'EUR'],
+ 5000: [0.5, 'EUR'],
+ },
+ 'link': f'https://example.com/sample-part-{size}-{length}-{material}',
+ 'image_url': r'https://github.com/inventree/demo-dataset/blob/main/media/part_images/flat-head.png?raw=true',
+ 'brand': 'Bolt Manufacturer',
+ })
+
+ def get_suppliers(self) -> list[supplier.Supplier]:
+ """Return a list of available suppliers."""
+ return [supplier.Supplier(slug='sample-fasteners', name='Sample Fasteners')]
+
+ def get_search_results(
+ self, supplier_slug: str, term: str
+ ) -> list[supplier.SearchResult]:
+ """Return a list of search results based on the search term."""
+ return [
+ supplier.SearchResult(
+ sku=p['sku'],
+ name=p['name'],
+ description=p['description'],
+ exact=p['sku'] == term,
+ price=f'{p["price"][1][0]:.2f}€',
+ link=p['link'],
+ image_url=p['image_url'],
+ existing_part=getattr(
+ SupplierPart.objects.filter(SKU=p['sku']).first(), 'part', None
+ ),
+ )
+ for p in self.sample_data
+ if all(t.lower() in p['name'].lower() for t in term.split())
+ ]
+
+ def get_import_data(self, supplier_slug: str, part_id: str):
+ """Return import data for a specific part ID."""
+ for p in self.sample_data:
+ if p['sku'] == part_id:
+ p = p.copy()
+ p['variants'] = [
+ x['sku']
+ for x in self.sample_data
+ if x['thread'] == p['thread'] and x['length'] == p['length']
+ ]
+ return p
+
+ raise supplier.PartNotFoundError()
+
+ def get_pricing_data(self, data) -> dict[int, tuple[float, str]]:
+ """Return pricing data for the given part data."""
+ return data['price']
+
+ def get_parameters(self, data) -> list[supplier.ImportParameter]:
+ """Return a list of parameters for the given part data."""
+ return [
+ supplier.ImportParameter(name='Thread', value=data['thread'][1:]),
+ supplier.ImportParameter(name='Length', value=f'{data["length"]}mm'),
+ supplier.ImportParameter(name='Material', value=data['material']),
+ supplier.ImportParameter(name='Head', value='Flat Head'),
+ ]
+
+ def import_part(self, data, **kwargs) -> Part:
+ """Import a part based on the provided data."""
+ part, created = Part.objects.get_or_create(
+ name__iexact=data['sku'],
+ purchaseable=True,
+ defaults={
+ 'name': data['sku'],
+ 'description': data['description'],
+ 'link': data['link'],
+ **kwargs,
+ },
+ )
+
+ # If the part was created, set additional fields
+ if created:
+ if data['image_url']:
+ file, fmt = self.download_image(data['image_url'])
+ filename = f'part_{part.pk}_image.{fmt.lower()}'
+ part.image.save(filename, file)
+
+ # link other variants if they exist in our inventree database
+ if len(data['variants']):
+ # search for other parts that may already have a template part associated
+ variant_parts = [
+ x.part
+ for x in SupplierPart.objects.filter(SKU__in=data['variants'])
+ ]
+ parent_part = self.get_template_part(
+ variant_parts,
+ {
+ # we cannot extract a real name for the root part, but we can try to guess a unique name
+ 'name': data['sku'].replace(data['material'] + '-', ''),
+ 'description': data['name'].replace(' ' + data['material'], ''),
+ 'link': data['link'],
+ 'image': part.image.name,
+ 'is_template': True,
+ **kwargs,
+ },
+ )
+
+ # after the template part was created, we need to refresh the part from the db because its tree id may have changed
+ # which results in an error if saved directly
+ part.refresh_from_db()
+ part.variant_of = parent_part # type: ignore
+ part.save()
+
+ return part
+
+ def import_manufacturer_part(self, data, **kwargs) -> ManufacturerPart:
+ """Import a manufacturer part based on the provided data."""
+ mft, _ = Company.objects.get_or_create(
+ name__iexact=data['brand'],
+ defaults={
+ 'is_manufacturer': True,
+ 'is_supplier': False,
+ 'name': data['brand'],
+ },
+ )
+
+ mft_part, created = ManufacturerPart.objects.get_or_create(
+ MPN=f'MAN-{data["sku"]}', manufacturer=mft, **kwargs
+ )
+
+ if created:
+ # Attachments, notes, parameters and more can be added here
+ pass
+
+ return mft_part
+
+ def import_supplier_part(self, data, **kwargs) -> SupplierPart:
+ """Import a supplier part based on the provided data."""
+ spp, _ = SupplierPart.objects.get_or_create(
+ SKU=data['sku'],
+ supplier=self.supplier_company,
+ **kwargs,
+ defaults={'link': data['link']},
+ )
+
+ SupplierPriceBreak.objects.filter(part=spp).delete()
+ SupplierPriceBreak.objects.bulk_create([
+ SupplierPriceBreak(
+ part=spp, quantity=quantity, price=price, price_currency=currency
+ )
+ for quantity, (price, currency) in data['price'].items()
+ ])
+
+ return spp
diff --git a/src/backend/InvenTree/plugin/samples/supplier/test_supplier_sample.py b/src/backend/InvenTree/plugin/samples/supplier/test_supplier_sample.py
new file mode 100644
index 0000000000..22f86a05a5
--- /dev/null
+++ b/src/backend/InvenTree/plugin/samples/supplier/test_supplier_sample.py
@@ -0,0 +1,211 @@
+"""Unit tests for locate_sample sample plugins."""
+
+from django.urls import reverse
+
+from company.models import ManufacturerPart, SupplierPart
+from InvenTree.unit_test import InvenTreeAPITestCase
+from part.models import (
+ Part,
+ PartCategory,
+ PartCategoryParameterTemplate,
+ PartParameterTemplate,
+)
+from plugin import registry
+
+
+class SampleSupplierTest(InvenTreeAPITestCase):
+ """Tests for SampleSupplierPlugin."""
+
+ fixtures = ['location', 'category', 'part', 'stock', 'company']
+ roles = ['part.add']
+
+ def test_list(self):
+ """Check the list api."""
+ # Test APIs
+ url = reverse('api-supplier-list')
+
+ # No plugin
+ res = self.get(url, expected_code=200)
+ self.assertEqual(len(res.data), 0)
+
+ # Activate plugin
+ config = registry.get_plugin('samplesupplier', active=None).plugin_config()
+ config.active = True
+ config.save()
+
+ # One active plugin
+ res = self.get(url, expected_code=200)
+ self.assertEqual(len(res.data), 1)
+ self.assertEqual(res.data[0]['plugin_slug'], 'samplesupplier')
+ self.assertEqual(res.data[0]['supplier_slug'], 'sample-fasteners')
+ self.assertEqual(res.data[0]['supplier_name'], 'Sample Fasteners')
+
+ def test_search(self):
+ """Check the search api."""
+ # Activate plugin
+ config = registry.get_plugin('samplesupplier', active=None).plugin_config()
+ config.active = True
+ config.save()
+
+ # Test APIs
+ url = reverse('api-supplier-search')
+
+ # No plugin
+ self.get(
+ url,
+ {'plugin': 'non-existent-plugin', 'supplier': 'sample-fasteners'},
+ expected_code=404,
+ )
+
+ # No supplier
+ self.get(
+ url,
+ {'plugin': 'samplesupplier', 'supplier': 'non-existent-supplier'},
+ expected_code=404,
+ )
+
+ # valid supplier
+ res = self.get(
+ url,
+ {'plugin': 'samplesupplier', 'supplier': 'sample-fasteners', 'term': 'M5'},
+ expected_code=200,
+ )
+ self.assertEqual(len(res.data), 15)
+ self.assertEqual(res.data[0]['sku'], 'BOLT-Steel-M5-5')
+
+ def test_import_part(self):
+ """Test importing a part by supplier."""
+ # Activate plugin
+ plugin = registry.get_plugin('samplesupplier', active=None)
+ config = plugin.plugin_config()
+ config.active = True
+ config.save()
+
+ # Test APIs
+ url = reverse('api-supplier-import')
+
+ # No plugin
+ self.post(
+ url,
+ {
+ 'plugin': 'non-existent-plugin',
+ 'supplier': 'sample-fasteners',
+ 'part_import_id': 'BOLT-Steel-M5-5',
+ },
+ expected_code=404,
+ )
+
+ # No supplier
+ self.post(
+ url,
+ {
+ 'plugin': 'samplesupplier',
+ 'supplier': 'non-existent-supplier',
+ 'part_import_id': 'BOLT-Steel-M5-5',
+ },
+ expected_code=404,
+ )
+
+ # valid supplier, no part or category provided
+ self.post(
+ url,
+ {
+ 'plugin': 'samplesupplier',
+ 'supplier': 'sample-fasteners',
+ 'part_import_id': 'BOLT-Steel-M5-5',
+ },
+ expected_code=400,
+ )
+
+ # valid supplier, but no supplier company set
+ self.post(
+ url,
+ {
+ 'plugin': 'samplesupplier',
+ 'supplier': 'sample-fasteners',
+ 'part_import_id': 'BOLT-Steel-M5-5',
+ 'category_id': 1,
+ },
+ expected_code=500,
+ )
+
+ # Set the supplier company now
+ plugin.set_setting('SUPPLIER', 1)
+
+ # valid supplier, valid part import
+ category = PartCategory.objects.get(pk=1)
+ p_len = PartParameterTemplate(name='Length', units='mm')
+ p_test = PartParameterTemplate(name='Test Parameter')
+ p_len.save()
+ p_test.save()
+ PartCategoryParameterTemplate.objects.bulk_create([
+ PartCategoryParameterTemplate(category=category, parameter_template=p_len),
+ PartCategoryParameterTemplate(
+ category=category, parameter_template=p_test, default_value='Test Value'
+ ),
+ ])
+ res = self.post(
+ url,
+ {
+ 'plugin': 'samplesupplier',
+ 'supplier': 'sample-fasteners',
+ 'part_import_id': 'BOLT-Steel-M5-5',
+ 'category_id': 1,
+ },
+ expected_code=200,
+ )
+ part = Part.objects.get(name='BOLT-Steel-M5-5')
+ self.assertIsNotNone(part)
+ self.assertEqual(part.pk, res.data['part_id'])
+
+ self.assertIsNotNone(SupplierPart.objects.get(pk=res.data['supplier_part_id']))
+ self.assertIsNotNone(
+ ManufacturerPart.objects.get(pk=res.data['manufacturer_part_id'])
+ )
+
+ self.assertSetEqual(
+ {x['name'] for x in res.data['parameters']},
+ {'Thread', 'Length', 'Material', 'Head', 'Test Parameter'},
+ )
+ for p in res.data['parameters']:
+ if p['name'] == 'Length':
+ self.assertEqual(p['value'], '5mm')
+ self.assertEqual(p['parameter_template'], p_len.pk)
+ self.assertTrue(p['on_category'])
+ elif p['name'] == 'Test Parameter':
+ self.assertEqual(p['value'], 'Test Value')
+ self.assertEqual(p['parameter_template'], p_test.pk)
+ self.assertTrue(p['on_category'])
+
+ # valid supplier, import only manufacturer and supplier part
+ part2 = Part.objects.create(name='Test Part', purchaseable=True)
+ res = self.post(
+ url,
+ {
+ 'plugin': 'samplesupplier',
+ 'supplier': 'sample-fasteners',
+ 'part_import_id': 'BOLT-Steel-M5-10',
+ 'part_id': part2.pk,
+ },
+ expected_code=200,
+ )
+
+ self.assertEqual(part2.pk, res.data['part_id'])
+ sp = SupplierPart.objects.get(pk=res.data['supplier_part_id'])
+ mp = ManufacturerPart.objects.get(pk=res.data['manufacturer_part_id'])
+ self.assertIsNotNone(sp)
+ self.assertIsNotNone(mp)
+ self.assertEqual(sp.part.pk, part2.pk)
+ self.assertEqual(mp.part.pk, part2.pk)
+
+ # PartNotFoundError
+ self.post(
+ url,
+ {
+ 'plugin': 'samplesupplier',
+ 'supplier': 'sample-fasteners',
+ 'part_import_id': 'non-existent-part',
+ 'category_id': 1,
+ },
+ expected_code=404,
+ )
diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx
index badd9e14c2..1ee2ba3806 100644
--- a/src/frontend/lib/enums/ApiEndpoints.tsx
+++ b/src/frontend/lib/enums/ApiEndpoints.tsx
@@ -220,6 +220,9 @@ export enum ApiEndpoints {
// Special plugin endpoints
plugin_locate_item = 'locate/',
+ plugin_supplier_list = 'supplier/list/',
+ plugin_supplier_search = 'supplier/search/',
+ plugin_supplier_import = 'supplier/import/',
// Machine API endpoints
machine_types_list = 'machine/types/',
diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx
index 365a6eb056..c442fb5820 100644
--- a/src/frontend/src/components/forms/ApiForm.tsx
+++ b/src/frontend/src/components/forms/ApiForm.tsx
@@ -115,6 +115,8 @@ export function OptionsApiForm({
if (!_props.fields) return _props;
+ _props.fields = { ..._props.fields };
+
for (const [k, v] of Object.entries(_props.fields)) {
_props.fields[k] = constructField({
field: v,
diff --git a/src/frontend/src/components/wizards/ImportPartWizard.tsx b/src/frontend/src/components/wizards/ImportPartWizard.tsx
new file mode 100644
index 0000000000..bc4b67ff72
--- /dev/null
+++ b/src/frontend/src/components/wizards/ImportPartWizard.tsx
@@ -0,0 +1,811 @@
+import { ApiEndpoints, ModelType, apiUrl } from '@lib/index';
+import { t } from '@lingui/core/macro';
+import { Trans } from '@lingui/react/macro';
+import {
+ ActionIcon,
+ Badge,
+ Box,
+ Button,
+ Center,
+ Checkbox,
+ Divider,
+ Group,
+ Loader,
+ Paper,
+ ScrollAreaAutosize,
+ Select,
+ Stack,
+ Text,
+ TextInput,
+ Title,
+ Tooltip
+} from '@mantine/core';
+import { showNotification } from '@mantine/notifications';
+import { IconArrowDown, IconPlus } from '@tabler/icons-react';
+import { useQuery } from '@tanstack/react-query';
+import {
+ type FormEventHandler,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState
+} from 'react';
+import { Link } from 'react-router-dom';
+import { api } from '../../App';
+import { usePartFields } from '../../forms/PartForms';
+import { InvenTreeIcon } from '../../functions/icons';
+import { useEditApiFormModal } from '../../hooks/UseForm';
+import useWizard from '../../hooks/UseWizard';
+import { StockItemTable } from '../../tables/stock/StockItemTable';
+import { StandaloneField } from '../forms/StandaloneField';
+import { RenderRemoteInstance } from '../render/Instance';
+
+type SearchResult = {
+ id: string;
+ sku: string;
+ name: string;
+ exact: boolean;
+ description?: string;
+ price?: string;
+ link?: string;
+ image_url?: string;
+ existing_part_id?: number;
+};
+
+type ImportResult = {
+ manufacturer_part_id: number;
+ supplier_part_id: number;
+ part_id: number;
+ pricing: { [priceBreak: number]: [number, string] };
+ part_detail: any;
+ parameters: {
+ name: string;
+ value: string;
+ parameter_template: number | null;
+ on_category: boolean;
+ }[];
+};
+
+const SearchResult = ({
+ searchResult,
+ partId,
+ rightSection
+}: {
+ searchResult: SearchResult;
+ partId?: number;
+ rightSection?: ReactNode;
+}) => {
+ return (
+
+
+ {searchResult.image_url && (
+
+ )}
+
+
+
+ {searchResult.name} ({searchResult.sku})
+
+
+ {searchResult.description}
+
+
+ {searchResult.price && (
+
+ {searchResult.price}
+
+ )}
+ {searchResult.exact && (
+
+ Exact Match
+
+ )}
+ {searchResult.existing_part_id &&
+ partId &&
+ searchResult.existing_part_id === partId && (
+
+ Current part
+
+ )}
+ {searchResult.existing_part_id && (
+
+
+ Already Imported
+
+
+ )}
+
+ {rightSection}
+
+
+
+ );
+};
+
+const SearchStep = ({
+ selectSupplierPart,
+ partId
+}: {
+ selectSupplierPart: (props: {
+ plugin: string;
+ supplier: string;
+ searchResult: SearchResult;
+ }) => void;
+ partId?: number;
+}) => {
+ const [searchValue, setSearchValue] = useState('');
+ const [supplier, setSupplier] = useState('');
+ const [searchResults, setSearchResults] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const supplierQuery = useQuery<
+ { plugin_slug: string; supplier_slug: string; supplier_name: string }[]
+ >({
+ queryKey: ['supplier-import-list'],
+ queryFn: () =>
+ api
+ .get(apiUrl(ApiEndpoints.plugin_supplier_list))
+ .then((response) => response.data ?? []),
+ enabled: true
+ });
+
+ const handleSearch = useCallback>(
+ async (e) => {
+ e.preventDefault();
+ if (!searchValue || !supplier) return;
+
+ setIsLoading(true);
+ const [plugin_slug, supplier_slug] = JSON.parse(supplier);
+ const res = await api.get(apiUrl(ApiEndpoints.plugin_supplier_search), {
+ params: {
+ plugin: plugin_slug,
+ supplier: supplier_slug,
+ term: searchValue
+ }
+ });
+ setSearchResults(res.data ?? []);
+ setIsLoading(false);
+ },
+ [supplier, searchValue]
+ );
+
+ useEffect(() => {
+ if (
+ supplier === '' &&
+ supplierQuery.data &&
+ supplierQuery.data.length > 0
+ ) {
+ setSupplier(
+ JSON.stringify([
+ supplierQuery.data[0].plugin_slug,
+ supplierQuery.data[0].supplier_slug
+ ])
+ );
+ }
+ }, [supplierQuery.data]);
+
+ return (
+
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {!isLoading && (
+
+ Found {searchResults.length} results
+
+ )}
+
+
+
+ {searchResults.map((res) => (
+
+ {
+ const [plugin_slug, supplier_slug] =
+ JSON.parse(supplier);
+
+ selectSupplierPart({
+ plugin: plugin_slug,
+ supplier: supplier_slug,
+ searchResult: res
+ });
+ }}
+ >
+
+
+
+ )
+ }
+ />
+ ))}
+
+
+
+ );
+};
+
+const CategoryStep = ({
+ categoryId,
+ importPart,
+ isImporting
+}: {
+ isImporting: boolean;
+ categoryId?: number;
+ importPart: (categoryId: number) => void;
+}) => {
+ const [category, setCategory] = useState(categoryId);
+
+ return (
+
+ setCategory(value)
+ }}
+ />
+
+
+
+ Are you sure you want to import this part into the selected category
+ now?
+
+
+
+
+
+
+
+ );
+};
+
+type ParametersType = (ImportResult['parameters'][number] & { use: boolean })[];
+
+const ParametersStep = ({
+ importResult,
+ isImporting,
+ skipStep,
+ importParameters,
+ parameterErrors
+}: {
+ importResult: ImportResult;
+ isImporting: boolean;
+ skipStep: () => void;
+ importParameters: (parameters: ParametersType) => Promise;
+ parameterErrors: { template?: string; data?: string }[] | null;
+}) => {
+ const [parameters, setParameters] = useState(() =>
+ importResult.parameters.map((p) => ({
+ ...p,
+ use: p.parameter_template !== null
+ }))
+ );
+ const [categoryCount, otherCount] = useMemo(() => {
+ const c = parameters.filter((x) => x.on_category && x.use).length;
+ const o = parameters.filter((x) => !x.on_category && x.use).length;
+ return [c, o];
+ }, [parameters]);
+ const parametersFromCategory = useMemo(
+ () => parameters.filter((x) => x.on_category).length,
+ [parameters]
+ );
+ const setParameter = useCallback(
+ (i: number, key: string) => (e: unknown) =>
+ setParameters((p) => p.map((p, j) => (i === j ? { ...p, [key]: e } : p))),
+ []
+ );
+
+ return (
+
+
+
+ Select and edit the parameters you want to add to this part.
+
+
+
+ {parametersFromCategory > 0 && (
+
+ Default category parameters
+ {categoryCount}
+
+ )}
+
+ {parameters.map((p, i) => (
+
+ {p.on_category === false &&
+ parameters[i - 1]?.on_category === true && (
+ <>
+
+
+ Other parameters
+ {otherCount}
+
+ >
+ )}
+
+
+ setParameter(i, 'use')(e.currentTarget.checked)
+ }
+ />
+ {!p.on_category && (
+
+
+ {p.name}
+
+
+ )}
+
+ {
+ if (!p.parameter_template) setParameter(i, 'use')(true);
+ setParameter(i, 'parameter_template')(v);
+ },
+ error: parameterErrors?.[i]?.template
+ }}
+ />
+
+
+ setParameter(i, 'value')(e.currentTarget.value)
+ }
+ error={parameterErrors?.[i]?.data}
+ />
+
+
+ ))}
+
+
+ {
+ setParameters((p) => [
+ ...p,
+ {
+ name: '',
+ value: '',
+ parameter_template: null,
+ on_category: false,
+ use: true
+ }
+ ]);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const StockStep = ({
+ importResult,
+ nextStep
+}: {
+ importResult: ImportResult;
+ nextStep: () => void;
+}) => {
+ return (
+
+
+ Create initial stock for the imported part.
+
+
+
+
+
+
+
+
+ );
+};
+
+export default function ImportPartWizard({
+ categoryId,
+ partId
+}: {
+ categoryId?: number;
+ partId?: number;
+}) {
+ const [supplierPart, setSupplierPart] = useState<{
+ plugin: string;
+ supplier: string;
+ searchResult: SearchResult;
+ }>();
+ const [importResult, setImportResult] = useState();
+ const [isImporting, setIsImporting] = useState(false);
+ const [parameterErrors, setParameterErrors] = useState<
+ { template?: string; data?: string }[] | null
+ >(null);
+
+ const partFields = usePartFields({ create: false });
+ const editPart = useEditApiFormModal({
+ url: ApiEndpoints.part_list,
+ pk: importResult?.part_id,
+ title: t`Edit Part`,
+ fields: partFields
+ });
+
+ const importPart = useCallback(
+ async ({
+ categoryId,
+ partId
+ }: { categoryId?: number; partId?: number }) => {
+ setIsImporting(true);
+ try {
+ const importResult = await api.post(
+ apiUrl(ApiEndpoints.plugin_supplier_import),
+ {
+ category_id: categoryId,
+ part_import_id: supplierPart?.searchResult.id,
+ plugin: supplierPart?.plugin,
+ supplier: supplierPart?.supplier,
+ part_id: partId
+ },
+ {
+ timeout: 30000 // 30 seconds
+ }
+ );
+ setImportResult(importResult.data);
+ showNotification({
+ title: t`Success`,
+ message: t`Part imported successfully!`,
+ color: 'green'
+ });
+ wizard.nextStep();
+ setIsImporting(false);
+ } catch (err: any) {
+ showNotification({
+ title: t`Error`,
+ message:
+ t`Failed to import part: ` +
+ (err?.response?.data?.detail || err.message),
+ color: 'red'
+ });
+ setIsImporting(false);
+ }
+ },
+ [supplierPart]
+ );
+
+ // Render the select wizard step
+ const renderStep = useCallback(
+ (step: number) => {
+ return (
+
+ {editPart.modal}
+
+ {step > 0 && supplierPart && (
+
+
+
+
+ {
+ editPart.open();
+ }}
+ >
+
+
+
+ )
+ }
+ />
+ )}
+
+ {step === 0 && (
+ {
+ setSupplierPart(sp);
+ wizard.nextStep();
+ }}
+ partId={partId}
+ />
+ )}
+
+ {!partId && step === 1 && (
+ {
+ importPart({ categoryId });
+ }}
+ />
+ )}
+
+ {!!partId && step === 1 && (
+
+
+
+
+
+ Are you sure, you want to import the supplier and manufacturer
+ part into this part?
+
+
+
+
+
+
+
+ )}
+
+ {!partId && step === 2 && (
+ {
+ setIsImporting(true);
+ setParameterErrors(null);
+ const useParameters = parameters
+ .map((x, i) => ({ ...x, i }))
+ .filter((p) => p.use);
+ const map = useParameters.reduce(
+ (acc, p, i) => {
+ acc[p.i] = i;
+ return acc;
+ },
+ {} as Record
+ );
+ const createParameters = useParameters.map((p) => ({
+ part: importResult!.part_id,
+ template: p.parameter_template,
+ data: p.value
+ }));
+ try {
+ await api.post(
+ apiUrl(ApiEndpoints.part_parameter_list),
+ createParameters
+ );
+ showNotification({
+ title: t`Success`,
+ message: t`Parameters created successfully!`,
+ color: 'green'
+ });
+ wizard.nextStep();
+ setIsImporting(false);
+ } catch (err: any) {
+ if (
+ err?.response?.status === 400 &&
+ Array.isArray(err.response.data)
+ ) {
+ const errors = err.response.data.map(
+ (e: Record) => {
+ const err: { data?: string; template?: string } = {};
+ if (e.data) err.data = e.data.join(',');
+ if (e.template) err.template = e.template.join(',');
+ return err;
+ }
+ );
+ setParameterErrors(
+ parameters.map((_, i) =>
+ map[i] !== undefined && errors[map[i]]
+ ? errors[map[i]]
+ : {}
+ )
+ );
+ }
+ showNotification({
+ title: t`Error`,
+ message: t`Failed to create parameters, please fix the errors and try again`,
+ color: 'red'
+ });
+ setIsImporting(false);
+ }
+ }}
+ skipStep={() => wizard.nextStep()}
+ />
+ )}
+
+ {step === (!partId ? 3 : 2) && (
+ wizard.nextStep()}
+ />
+ )}
+
+ {step === (!partId ? 4 : 3) && (
+
+
+
+ Part imported successfully from supplier{' '}
+ {supplierPart?.supplier}.
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+ },
+ [
+ partId,
+ categoryId,
+ supplierPart,
+ importResult,
+ isImporting,
+ parameterErrors,
+ importPart,
+ editPart.modal
+ ]
+ );
+
+ const onClose = useCallback(() => {
+ setSupplierPart(undefined);
+ setImportResult(undefined);
+ setIsImporting(false);
+ setParameterErrors(null);
+ wizard.setStep(0);
+ }, []);
+
+ // Create the wizard manager
+ const wizard = useWizard({
+ title: t`Import Part`,
+ steps: [
+ t`Search Supplier Part`,
+ // if partId is provided, a inventree part already exists, just import the mp/sp
+ ...(!partId ? [t`Category`, t`Parameters`] : [t`Confirm import`]),
+ t`Stock`,
+ t`Done`
+ ],
+ onClose,
+ renderStep: renderStep,
+ disableManualStepChange: true
+ });
+
+ return wizard;
+}
diff --git a/src/frontend/src/components/wizards/WizardDrawer.tsx b/src/frontend/src/components/wizards/WizardDrawer.tsx
index 20cac17663..1d5ea53ae9 100644
--- a/src/frontend/src/components/wizards/WizardDrawer.tsx
+++ b/src/frontend/src/components/wizards/WizardDrawer.tsx
@@ -5,7 +5,6 @@ import {
Divider,
Drawer,
Group,
- Paper,
Space,
Stack,
Stepper,
@@ -26,11 +25,13 @@ import { StylishText } from '../items/StylishText';
function WizardProgressStepper({
currentStep,
steps,
- onSelectStep
+ onSelectStep,
+ disableManualStepChange = false
}: {
currentStep: number;
steps: string[];
onSelectStep: (step: number) => void;
+ disableManualStepChange?: boolean;
}) {
if (!steps || steps.length == 0) {
return null;
@@ -54,23 +55,32 @@ function WizardProgressStepper({
return (
-
-
- onSelectStep(currentStep - 1)}
+
+ {!disableManualStepChange && (
+
-
-
-
+ onSelectStep(currentStep - 1)}
+ disabled={!canStepBackward}
+ >
+
+
+
+ )}
onSelectStep(stepIndex)}
+ onStepClick={(stepIndex: number) => {
+ if (disableManualStepChange) return;
+ onSelectStep(stepIndex);
+ }}
iconSize={20}
size='xs'
>
@@ -84,19 +94,21 @@ function WizardProgressStepper({
))}
{canStepForward ? (
-
- onSelectStep(currentStep + 1)}
+ !disableManualStepChange && (
+
-
-
-
+ onSelectStep(currentStep + 1)}
+ disabled={!canStepForward || disableManualStepChange}
+ >
+
+
+
+ )
) : (
@@ -120,7 +132,8 @@ export default function WizardDrawer({
opened,
onClose,
onNextStep,
- onPreviousStep
+ onPreviousStep,
+ disableManualStepChange
}: {
title: string;
currentStep: number;
@@ -130,6 +143,7 @@ export default function WizardDrawer({
onClose: () => void;
onNextStep?: () => void;
onPreviousStep?: () => void;
+ disableManualStepChange?: boolean;
}) {
const titleBlock: ReactNode = useMemo(() => {
return (
@@ -145,7 +159,9 @@ export default function WizardDrawer({
{
+ if (disableManualStepChange) return;
if (step < currentStep) {
onPreviousStep?.();
} else {
@@ -179,10 +195,7 @@ export default function WizardDrawer({
opened={opened}
onClose={onClose}
>
-
- {}
- {children}
-
+ {children}
);
}
diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx
index d00af0a968..b1a700a318 100644
--- a/src/frontend/src/forms/StockForms.tsx
+++ b/src/frontend/src/forms/StockForms.tsx
@@ -67,22 +67,33 @@ import { StatusFilterOptions } from '../tables/Filter';
export function useStockFields({
partId,
stockItem,
- modalId,
- create = false
+ create = false,
+ supplierPartId,
+ pricing,
+ modalId
}: {
partId?: number;
stockItem?: any;
modalId: string;
create: boolean;
+ supplierPartId?: number;
+ pricing?: { [priceBreak: number]: [number, string] };
}): ApiFormFieldSet {
const globalSettings = useGlobalSettingsState();
// Keep track of the "part" instance
const [partInstance, setPartInstance] = useState({});
- const [supplierPart, setSupplierPart] = useState(null);
+ const [supplierPart, setSupplierPart] = useState(
+ supplierPartId ?? null
+ );
const [expiryDate, setExpiryDate] = useState(null);
+ const [quantity, setQuantity] = useState(null);
+ const [purchasePrice, setPurchasePrice] = useState(null);
+ const [purchasePriceCurrency, setPurchasePriceCurrency] = useState<
+ string | null
+ >(null);
const batchGenerator = useBatchCodeGenerator({
modalId: modalId,
@@ -98,11 +109,30 @@ export function useStockFields({
}
});
+ // Update pricing when quantity changes
+ useEffect(() => {
+ if (quantity === null || quantity === undefined || !pricing) return;
+
+ // Find the highest price break that is less than or equal to the quantity
+ const priceBreak = Object.entries(pricing)
+ .sort(([a], [b]) => Number.parseInt(b) - Number.parseInt(a))
+ .find(([br]) => quantity >= Number.parseInt(br));
+
+ if (priceBreak) {
+ setPurchasePrice(priceBreak[1][0]);
+ setPurchasePriceCurrency(priceBreak[1][1]);
+ }
+ }, [pricing, quantity]);
+
+ useEffect(() => {
+ if (supplierPartId && !supplierPart) setSupplierPart(supplierPartId);
+ }, [partInstance, supplierPart, supplierPartId]);
+
return useMemo(() => {
const fields: ApiFormFieldSet = {
part: {
- value: partId || partInstance?.pk,
- disabled: !create,
+ value: partInstance.pk,
+ disabled: !create || !!partId,
filters: {
virtual: false,
active: create ? true : undefined
@@ -135,6 +165,7 @@ export function useStockFields({
},
supplier_part: {
hidden: partInstance?.purchaseable == false,
+ disabled: !!supplierPartId,
value: supplierPart,
onValueChange: (value) => {
setSupplierPart(value);
@@ -171,6 +202,7 @@ export function useStockFields({
description: t`Enter initial quantity for this stock item`,
onValueChange: (value) => {
batchGenerator.update({ quantity: value });
+ setQuantity(value);
}
},
serial_numbers: {
@@ -210,10 +242,18 @@ export function useStockFields({
}
},
purchase_price: {
- icon:
+ icon: ,
+ value: purchasePrice,
+ onValueChange: (value) => {
+ setPurchasePrice(value);
+ }
},
purchase_price_currency: {
- icon:
+ icon: ,
+ value: purchasePriceCurrency,
+ onValueChange: (value) => {
+ setPurchasePriceCurrency(value);
+ }
},
packaging: {
icon:
@@ -240,6 +280,10 @@ export function useStockFields({
partId,
globalSettings,
supplierPart,
+ create,
+ supplierPartId,
+ purchasePrice,
+ purchasePriceCurrency,
serialGenerator.result,
batchGenerator.result,
create
diff --git a/src/frontend/src/hooks/UseWizard.tsx b/src/frontend/src/hooks/UseWizard.tsx
index e8f06f9953..7e9d45224e 100644
--- a/src/frontend/src/hooks/UseWizard.tsx
+++ b/src/frontend/src/hooks/UseWizard.tsx
@@ -12,6 +12,8 @@ import WizardDrawer from '../components/wizards/WizardDrawer';
export interface WizardProps {
title: string;
steps: string[];
+ disableManualStepChange?: boolean;
+ onClose?: () => void;
renderStep: (step: number) => ReactNode;
canStepForward?: (step: number) => boolean;
canStepBackward?: (step: number) => boolean;
@@ -30,6 +32,7 @@ export interface WizardState {
nextStep: () => void;
previousStep: () => void;
wizard: ReactNode;
+ setStep: (step: number) => void;
}
/**
@@ -65,32 +68,44 @@ export default function useWizard(props: WizardProps): WizardState {
// Close the wizard
const closeWizard = useCallback(() => {
+ props.onClose?.();
setOpened(false);
}, []);
// Progress the wizard to the next step
const nextStep = useCallback(() => {
- if (props.canStepForward && !props.canStepForward(currentStep)) {
- return;
- }
-
- if (props.steps && currentStep < props.steps.length - 1) {
- setCurrentStep(currentStep + 1);
- clearError();
- }
- }, [currentStep, props.canStepForward]);
+ setCurrentStep((c) => {
+ if (props.canStepForward && !props.canStepForward(c)) {
+ return c;
+ }
+ const newStep = Math.min(c + 1, props.steps.length - 1);
+ if (newStep !== c) clearError();
+ return newStep;
+ });
+ }, [props.canStepForward]);
// Go back to the previous step
const previousStep = useCallback(() => {
- if (props.canStepBackward && !props.canStepBackward(currentStep)) {
- return;
- }
+ setCurrentStep((c) => {
+ if (props.canStepBackward && !props.canStepBackward(c)) {
+ return c;
+ }
+ const newStep = Math.max(c - 1, 0);
+ if (newStep !== c) clearError();
+ return newStep;
+ });
+ }, [props.canStepBackward]);
- if (currentStep > 0) {
- setCurrentStep(currentStep - 1);
+ const setStep = useCallback(
+ (step: number) => {
+ if (step < 0 || step >= props.steps.length) {
+ return;
+ }
+ setCurrentStep(step);
clearError();
- }
- }, [currentStep, props.canStepBackward]);
+ },
+ [props.steps.length]
+ );
// Render the wizard contents for the current step
const contents = useMemo(() => {
@@ -109,8 +124,10 @@ export default function useWizard(props: WizardProps): WizardState {
closeWizard,
nextStep,
previousStep,
+ setStep,
wizard: (
{
const can_edit = user.hasChangePermission(ModelType.part);
@@ -482,9 +490,19 @@ export function PartListTable({
hidden={!user.hasAddRole(UserRoles.part)}
tooltip={t`Add Part`}
onClick={() => newPart.open()}
+ />,
+ }
+ onClick={() => importPartWizard.openWizard()}
/>
];
- }, [user, table.hasSelectedRecords]);
+ }, [user, table.hasSelectedRecords, supplierPlugins]);
return (
<>
@@ -493,6 +511,7 @@ export function PartListTable({
{editPart.modal}
{setCategory.modal}
{orderPartsWizard.wizard}
+ {importPartWizard.wizard}
{
return [
addSupplierPart.open()}
hidden={!user.hasAddRole(UserRoles.purchase_order)}
+ />,
+ }
+ color='green'
+ tooltip={t`Import supplier part`}
+ onClick={() => importPartWizard.openWizard()}
+ hidden={
+ supplierPlugins.length === 0 ||
+ !user.hasAddRole(UserRoles.part) ||
+ !params?.part
+ }
/>
];
- }, [user]);
+ }, [user, supplierPlugins]);
const tableFilters: TableFilter[] = useMemo(() => {
return [
@@ -244,6 +265,7 @@ export function SupplierPartTable({
{addSupplierPart.modal}
{editSupplierPart.modal}
{deleteSupplierPart.modal}
+ {importPartWizard.wizard}
{
// Returns a list that may contain multiple serialized stock items
diff --git a/src/frontend/tests/api.ts b/src/frontend/tests/api.ts
new file mode 100644
index 0000000000..7d4e694a65
--- /dev/null
+++ b/src/frontend/tests/api.ts
@@ -0,0 +1,10 @@
+import { request } from '@playwright/test';
+import { adminuser, apiUrl } from './defaults';
+
+export const createApi = () =>
+ request.newContext({
+ baseURL: apiUrl,
+ extraHTTPHeaders: {
+ Authorization: `Basic ${btoa(`${adminuser.username}:${adminuser.password}`)}`
+ }
+ });
diff --git a/src/frontend/tests/helpers.ts b/src/frontend/tests/helpers.ts
index cdfbcd8f86..1190946d11 100644
--- a/src/frontend/tests/helpers.ts
+++ b/src/frontend/tests/helpers.ts
@@ -1,3 +1,6 @@
+import { expect } from '@playwright/test';
+import { createApi } from './api';
+
/**
* Open the filter drawer for the currently visible table
* @param page - The page object
@@ -130,3 +133,20 @@ export const globalSearch = async (page, query) => {
await page.getByPlaceholder('Enter search text').fill(query);
await page.waitForTimeout(300);
};
+
+export const deletePart = async (name: string) => {
+ const api = await createApi();
+ const parts = await api
+ .get('part/', {
+ params: { search: name }
+ })
+ .then((res) => res.json());
+ const existingPart = parts.find((p: any) => p.name === name);
+ if (existingPart) {
+ await api.patch(`part/${existingPart.pk}/`, {
+ data: { active: false }
+ });
+ const res = await api.delete(`part/${existingPart.pk}/`);
+ expect(res.status()).toBe(204);
+ }
+};
diff --git a/src/frontend/tests/pages/pui_dashboard.spec.ts b/src/frontend/tests/pages/pui_dashboard.spec.ts
index 78e70e5150..4f5f56e062 100644
--- a/src/frontend/tests/pages/pui_dashboard.spec.ts
+++ b/src/frontend/tests/pages/pui_dashboard.spec.ts
@@ -39,10 +39,9 @@ test('Dashboard - Basic', async ({ browser }) => {
await page.getByLabel('dashboard-accept-layout').click();
});
-test('Dashboard - Plugins', async ({ browser, request }) => {
+test('Dashboard - Plugins', async ({ browser }) => {
// Ensure that the "SampleUI" plugin is enabled
await setPluginState({
- request,
plugin: 'sampleui',
state: true
});
diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts
index ac9eb973c5..f5c3679e05 100644
--- a/src/frontend/tests/pages/pui_part.spec.ts
+++ b/src/frontend/tests/pages/pui_part.spec.ts
@@ -2,12 +2,14 @@ import { test } from '../baseFixtures';
import {
clearTableFilters,
clickOnRowMenu,
+ deletePart,
getRowFromCell,
loadTab,
navigate,
setTableChoiceFilter
} from '../helpers';
import { doCachedLogin } from '../login';
+import { setPluginState, setSettingState } from '../settings';
/**
* CHeck each panel tab for the "Parts" page
@@ -645,3 +647,62 @@ test('Parts - Duplicate', async ({ browser }) => {
await page.getByText('Copy Parameters', { exact: true }).waitFor();
await page.getByText('Copy Tests', { exact: true }).waitFor();
});
+
+test('Parts - Import supplier part', async ({ browser }) => {
+ const page = await doCachedLogin(browser, {
+ url: 'part/category/1/parts'
+ });
+
+ // Ensure that the sample supplier plugin is enabled
+ await setPluginState({
+ plugin: 'samplesupplier',
+ state: true
+ });
+
+ await setSettingState({
+ setting: 'SUPPLIER',
+ value: 3,
+ type: 'plugin',
+ plugin: 'samplesupplier'
+ });
+
+ // cleanup old imported part if it exists
+ await deletePart('BOLT-Steel-M5-5');
+ await deletePart('BOLT-M5-5');
+
+ await page.reload();
+ await page.waitForLoadState('networkidle');
+ await page.waitForTimeout(1000);
+
+ await page.getByRole('button', { name: 'action-button-import-part' }).click();
+ await page
+ .getByRole('textbox', { name: 'textbox-search-for-part' })
+ .fill('M5');
+ await page.waitForTimeout(250);
+ await page
+ .getByRole('textbox', { name: 'textbox-search-for-part' })
+ .press('Enter');
+
+ await page.getByText('Bolt M5x5mm Steel').waitFor();
+ await page
+ .getByRole('button', { name: 'action-button-import-part-BOLT-Steel-M5-5' })
+ .click();
+ await page.waitForTimeout(250);
+ await page
+ .getByRole('button', { name: 'action-button-import-part-now' })
+ .click();
+
+ await page
+ .getByRole('button', { name: 'action-button-import-create-parameters' })
+ .dispatchEvent('click');
+ await page
+ .getByRole('button', { name: 'action-button-import-stock-next' })
+ .dispatchEvent('click');
+ await page
+ .getByRole('button', { name: 'action-button-import-close' })
+ .dispatchEvent('click');
+
+ // cleanup imported part if it exists
+ await deletePart('BOLT-Steel-M5-5');
+ await deletePart('BOLT-M5-5');
+});
diff --git a/src/frontend/tests/pui_machines.spec.ts b/src/frontend/tests/pui_machines.spec.ts
index 4a9d830a2f..156638bff1 100644
--- a/src/frontend/tests/pui_machines.spec.ts
+++ b/src/frontend/tests/pui_machines.spec.ts
@@ -18,7 +18,7 @@ test('Machines - Admin Panel', async ({ browser }) => {
await page.getByText('There are no machine registry errors').waitFor();
});
-test('Machines - Activation', async ({ browser, request }) => {
+test('Machines - Activation', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree',
@@ -27,7 +27,6 @@ test('Machines - Activation', async ({ browser, request }) => {
// Ensure that the sample machine plugin is enabled
await setPluginState({
- request,
plugin: 'sample-printer-machine-plugin',
state: true
});
diff --git a/src/frontend/tests/pui_permissions.spec.ts b/src/frontend/tests/pui_permissions.spec.ts
index 9c0f09d8bd..bf77225ff8 100644
--- a/src/frontend/tests/pui_permissions.spec.ts
+++ b/src/frontend/tests/pui_permissions.spec.ts
@@ -10,7 +10,7 @@ import { doCachedLogin } from './login';
* Test the "admin" account
* - This is a superuser account, so should have *all* permissions available
*/
-test('Permissions - Admin', async ({ browser, request }) => {
+test('Permissions - Admin', async ({ browser }) => {
// Login, and start on the "admin" page
const page = await doCachedLogin(browser, {
username: 'admin',
@@ -57,7 +57,7 @@ test('Permissions - Admin', async ({ browser, request }) => {
* Test the "reader" account
* - This account is read-only, but should be able to access *most* pages
*/
-test('Permissions - Reader', async ({ browser, request }) => {
+test('Permissions - Reader', async ({ browser }) => {
// Login, and start on the "admin" page
const page = await doCachedLogin(browser, {
username: 'reader',
diff --git a/src/frontend/tests/pui_plugins.spec.ts b/src/frontend/tests/pui_plugins.spec.ts
index 4ae0b6bf6d..8be8e3123d 100644
--- a/src/frontend/tests/pui_plugins.spec.ts
+++ b/src/frontend/tests/pui_plugins.spec.ts
@@ -11,7 +11,7 @@ import { doCachedLogin } from './login.js';
import { setPluginState, setSettingState } from './settings.js';
// Unit test for plugin settings
-test('Plugins - Settings', async ({ browser, request }) => {
+test('Plugins - Settings', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree'
@@ -19,7 +19,6 @@ test('Plugins - Settings', async ({ browser, request }) => {
// Ensure that the SampleIntegration plugin is enabled
await setPluginState({
- request,
plugin: 'sample',
state: true
});
@@ -63,12 +62,11 @@ test('Plugins - Settings', async ({ browser, request }) => {
await page.getByText('Mouser Electronics').click();
});
-test('Plugins - User Settings', async ({ browser, request }) => {
+test('Plugins - User Settings', async ({ browser }) => {
const page = await doCachedLogin(browser);
// Ensure that the SampleIntegration plugin is enabled
await setPluginState({
- request,
plugin: 'sample',
state: true
});
@@ -149,7 +147,7 @@ test('Plugins - Functionality', async ({ browser }) => {
.waitFor();
});
-test('Plugins - Panels', async ({ browser, request }) => {
+test('Plugins - Panels', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree'
@@ -157,14 +155,12 @@ test('Plugins - Panels', async ({ browser, request }) => {
// Ensure that UI plugins are enabled
await setSettingState({
- request,
setting: 'ENABLE_PLUGINS_INTERFACE',
value: true
});
// Ensure that the SampleUI plugin is enabled
await setPluginState({
- request,
plugin: 'sampleui',
state: true
});
@@ -192,7 +188,6 @@ test('Plugins - Panels', async ({ browser, request }) => {
// Disable the plugin, and ensure it is no longer visible
await setPluginState({
- request,
plugin: 'sampleui',
state: false
});
@@ -201,7 +196,7 @@ test('Plugins - Panels', async ({ browser, request }) => {
/**
* Unit test for custom admin integration for plugins
*/
-test('Plugins - Custom Admin', async ({ browser, request }) => {
+test('Plugins - Custom Admin', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree'
@@ -209,7 +204,6 @@ test('Plugins - Custom Admin', async ({ browser, request }) => {
// Ensure that the SampleUI plugin is enabled
await setPluginState({
- request,
plugin: 'sampleui',
state: true
});
@@ -235,7 +229,7 @@ test('Plugins - Custom Admin', async ({ browser, request }) => {
await page.getByText('hello: world').waitFor();
});
-test('Plugins - Locate Item', async ({ browser, request }) => {
+test('Plugins - Locate Item', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree'
@@ -243,7 +237,6 @@ test('Plugins - Locate Item', async ({ browser, request }) => {
// Ensure that the sample location plugin is enabled
await setPluginState({
- request,
plugin: 'samplelocate',
state: true
});
diff --git a/src/frontend/tests/pui_printing.spec.ts b/src/frontend/tests/pui_printing.spec.ts
index 7bcef70df4..abd25ce299 100644
--- a/src/frontend/tests/pui_printing.spec.ts
+++ b/src/frontend/tests/pui_printing.spec.ts
@@ -77,7 +77,7 @@ test('Printing - Report Printing', async ({ browser }) => {
await page.context().close();
});
-test('Printing - Report Editing', async ({ browser, request }) => {
+test('Printing - Report Editing', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree'
@@ -85,7 +85,6 @@ test('Printing - Report Editing', async ({ browser, request }) => {
// activate the sample plugin for this test
await setPluginState({
- request,
plugin: 'sampleui',
state: true
});
@@ -140,7 +139,6 @@ test('Printing - Report Editing', async ({ browser, request }) => {
// deactivate the sample plugin again after the test
await setPluginState({
- request,
plugin: 'sampleui',
state: false
});
diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts
index 2477f7f662..aac3c4e7d0 100644
--- a/src/frontend/tests/pui_settings.spec.ts
+++ b/src/frontend/tests/pui_settings.spec.ts
@@ -1,5 +1,5 @@
+import { createApi } from './api.js';
import { expect, test } from './baseFixtures.js';
-import { apiUrl } from './defaults.js';
import { getRowFromCell, loadTab, navigate } from './helpers.js';
import { doCachedLogin } from './login.js';
import { setPluginState, setSettingState } from './settings.js';
@@ -134,7 +134,7 @@ test('Settings - User', async ({ browser }) => {
.waitFor();
});
-test('Settings - Global', async ({ browser, request }) => {
+test('Settings - Global', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'steven',
password: 'wizardstaff',
@@ -144,7 +144,6 @@ test('Settings - Global', async ({ browser, request }) => {
// Ensure the "slack" notification plugin is enabled
// This is to ensure it is visible in the "notification" settings tab
await setPluginState({
- request,
plugin: 'inventree-slack-notification',
state: true
});
@@ -312,7 +311,7 @@ test('Settings - Admin', async ({ browser }) => {
await page.getByRole('button', { name: 'Submit' }).click();
});
-test('Settings - Admin - Barcode History', async ({ browser, request }) => {
+test('Settings - Admin - Barcode History', async ({ browser }) => {
// Login with admin credentials
const page = await doCachedLogin(browser, {
username: 'admin',
@@ -321,25 +320,21 @@ test('Settings - Admin - Barcode History', async ({ browser, request }) => {
// Ensure that the "save scans" setting is enabled
await setSettingState({
- request: request,
setting: 'BARCODE_STORE_RESULTS',
value: true
});
// Scan some barcodes (via API calls)
const barcodes = ['ABC1234', 'XYZ5678', 'QRS9012'];
+ const api = await createApi();
for (let i = 0; i < barcodes.length; i++) {
const barcode = barcodes[i];
- const url = new URL('barcode/', apiUrl).toString();
- await request.post(url, {
+ await api.post('barcode/', {
data: {
barcode: barcode
},
- timeout: 5000,
- headers: {
- Authorization: `Basic ${btoa('admin:inventree')}`
- }
+ timeout: 5000
});
}
diff --git a/src/frontend/tests/settings.ts b/src/frontend/tests/settings.ts
index 2d828f2012..a8fcdc0a68 100644
--- a/src/frontend/tests/settings.ts
+++ b/src/frontend/tests/settings.ts
@@ -1,54 +1,48 @@
import { expect } from 'playwright/test';
-import { apiUrl } from './defaults';
+import { createApi } from './api';
/*
* Set the value of a global setting in the database
*/
export const setSettingState = async ({
- request,
setting,
- value
+ value,
+ type = 'global',
+ plugin
}: {
- request: any;
setting: string;
value: any;
+ type?: 'global' | 'plugin';
+ plugin?: string;
}) => {
- const url = new URL(`settings/global/${setting}/`, apiUrl).toString();
-
- const response = await request.patch(url, {
+ const api = await createApi();
+ const url =
+ type === 'global'
+ ? `settings/global/${setting}/`
+ : `plugins/${plugin}/settings/${setting}/`;
+ const response = await api.patch(url, {
data: {
value: value
- },
- headers: {
- // Basic username: password authorization
- Authorization: `Basic ${btoa('admin:inventree')}`
}
});
- expect(await response.status()).toBe(200);
+ expect(response.status()).toBe(200);
};
export const setPluginState = async ({
- request,
plugin,
state
}: {
- request: any;
plugin: string;
state: boolean;
}) => {
- const url = new URL(`plugins/${plugin}/activate/`, apiUrl).toString();
-
- const response = await request.patch(url, {
+ const api = await createApi();
+ const response = await api.patch(`plugins/${plugin}/activate/`, {
data: {
active: state
- },
- headers: {
- // Basic username: password authorization
- Authorization: `Basic ${btoa('admin:inventree')}`
}
});
- expect(await response.status()).toBe(200);
+ expect(response.status()).toBe(200);
};