diff --git a/docs/docs/plugins/mixins/supplier.md b/docs/docs/plugins/mixins/supplier.md new file mode 100644 index 000000000000..de92b767dd78 --- /dev/null +++ b/docs/docs/plugins/mixins/supplier.md @@ -0,0 +1,47 @@ +--- +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 + +When a user initiates a search through the UI, the `get_search_results` function is called, and the search results are returned. These contain a `part_id` which is then passed to `get_import_data` 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 712dce5cf2e1..86e3da22424e 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -228,6 +228,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 - URL Mixin: plugins/mixins/urls.md - User Interface Mixin: plugins/mixins/ui.md - Validation Mixin: plugins/mixins/validation.md diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 0dea53508bf3..5d7d478807f3 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 = 348 +INVENTREE_API_VERSION = 349 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v349 -> 2025-06-12 : https://github.com/inventree/InvenTree/pull/9761 + - Add supplier search and import API endpoints + - Add part parameter bulk create API endpoint + v348 -> 2025-04-22 : https://github.com/inventree/InvenTree/pull/9312 - Adds "external" flag for BuildOrder - Adds link between PurchaseOrderLineItem and BuildOrder diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 9aa653c1af58..bd4e5ff1dd2c 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -529,3 +529,52 @@ def validate_remote_image(self, url): raise ValidationError(_('Failed to download image from remote URL')) return url + + +class ListUniqueValidator: + """List validator that validates unique fields for bulk create. + + See: https://github.com/encode/django-rest-framework/issues/6395#issuecomment-452412653 + """ + + message = 'This field must be unique.' + + def __init__(self, unique_field_names): + """Initialize the validator with a list of unique field names.""" + self.unique_field_names = unique_field_names + + @staticmethod + def has_duplicates(counter): + """Check if there are any duplicate values in the counter.""" + return any(count for count in counter.values() if count > 1) + + def __call__(self, value): + """Validate that the specified fields are unique across the list of items.""" + from collections import Counter + + field_counters = { + field_name: Counter( + item[field_name] for item in value if field_name in item + ) + for field_name in self.unique_field_names + } + has_duplicates = any( + ListUniqueValidator.has_duplicates(counter) + for counter in field_counters.values() + ) + if has_duplicates: + errors = [] + for item in value: + error = {} + for field_name in self.unique_field_names: + counter = field_counters[field_name] + if counter[item.get(field_name)] > 1: + error[field_name] = self.message + errors.append(error) + raise ValidationError(errors) + + def __repr__(self): + """Return a string representation of the validator.""" + return ( + f'<{self.__class__.__name__}(unique_field_names={self.unique_field_names})>' + ) diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index d86c3c17f478..c29e91828fd8 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -14,6 +14,7 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.exceptions import ValidationError +from rest_framework.generics import CreateAPIView from rest_framework.response import Response import InvenTree.permissions @@ -1700,6 +1701,23 @@ class PartParameterList(PartParameterAPIMixin, DataExportViewMixin, ListCreateAP ] +class PartParameterBulkCreate(CreateAPIView): + """Bulk create part parameters. + + - POST: Bulk create part parameters + """ + + serializer_class = part_serializers.PartParameterBulkSerializer + queryset = PartParameter.objects.all() + + def get_serializer(self, *args, **kwargs): + """Return the serializer instance for this API endpoint.""" + if isinstance(kwargs.get('data', {}), list): + kwargs['many'] = True + + return super().get_serializer(*args, **kwargs) + + class PartParameterDetail(PartParameterAPIMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a single PartParameter object.""" @@ -2182,6 +2200,11 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): ), ]), ), + path( + 'bulk/', + PartParameterBulkCreate.as_view(), + name='api-part-parameter-bulk-create', + ), path('', PartParameterList.as_view(), name='api-part-parameter-list'), ]), ), diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 875d2f64c355..12872444a21e 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -472,6 +472,29 @@ def __init__(self, *args, **kwargs): ) +class TemplateUniquenessListSerializer(serializers.ListSerializer): + """List serializer that validates unique fields for bulk create.""" + + validators = [ + InvenTree.serializers.ListUniqueValidator(unique_field_names=['template']) + ] + + +class PartParameterBulkSerializer(InvenTree.serializers.InvenTreeModelSerializer): + """JSON serializers for the PartParameter model.""" + + class Meta: + """Metaclass defining serializer fields.""" + + model = PartParameter + fields = ['pk', 'part', 'template', 'data', 'data_numeric'] + list_serializer_class = TemplateUniquenessListSerializer + + def __init__(self, *args, **kwargs): + """Custom initialization method for the serializer.""" + serializers.ModelSerializer.__init__(self, *args, **kwargs) + + class DuplicatePartSerializer(serializers.Serializer): """Serializer for specifying options when duplicating a Part. diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py index dacd6e2ac8c9..f016b59ac5c5 100644 --- a/src/backend/InvenTree/plugin/api.py +++ b/src/backend/InvenTree/plugin/api.py @@ -30,6 +30,7 @@ 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 from plugin.plugin import InvenTreePlugin @@ -525,4 +526,5 @@ class PluginMetadataView(MetadataView): 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 000000000000..e69de29bb2d1 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 000000000000..c89b9b702ad4 --- /dev/null +++ b/src/backend/InvenTree/plugin/base/supplier/api.py @@ -0,0 +1,200 @@ +"""API views for supplier plugins in InvenTree.""" + +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, +) + +# from .supplier import ImportParameter, PartNotFoundError + + +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='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.""" + supplier_slug = request.query_params.get('supplier', '') + + supplier = None + for plugin in registry.with_mixin(PluginMixinEnum.SUPPLIER): + if plugin.slug == supplier_slug: + supplier = plugin + break + + if not supplier: + raise NotFound(detail=f"Supplier '{supplier_slug}' not found") + + term = request.query_params.get('term', '') + try: + results = supplier.get_search_results(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 + supplier_slug = serializer.validated_data.get('supplier', '') + part_import_id = serializer.validated_data.get('part_import_id', None) + category = serializer.validated_data.get('category_id', None) + part = serializer.validated_data.get('part_id', None) + + # Find the supplier plugin + supplier = None + for plugin in registry.with_mixin(PluginMixinEnum.SUPPLIER): + if plugin.slug == supplier_slug: + supplier = plugin + break + + # Validate supplier and part/category + if not supplier: + raise NotFound(detail=f"Supplier '{supplier_slug}' not found") + 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 SupplierMixin + + # Import part data + try: + import_data = supplier.get_import_data(part_import_id) + + with transaction.atomic(): + # create part if it does not exist + if not part: + part = supplier.import_part( + import_data, category=category, creation_user=request.user + ) + + # create manufacturer part + manufacturer_part = supplier.import_manufacturer_part( + import_data, part=part + ) + + # create supplier part + supplier_part = supplier.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.get_pricing_data(import_data) + + # get parameters + parameters = supplier.get_parameters(import_data) + except SupplierMixin.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( + SupplierMixin.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('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/mixins.py b/src/backend/InvenTree/plugin/base/supplier/mixins.py new file mode 100644 index 000000000000..e664bb2606e9 --- /dev/null +++ b/src/backend/InvenTree/plugin/base/supplier/mixins.py @@ -0,0 +1,217 @@ +"""Plugin mixin class for Supplier Integration.""" + +import io +from dataclasses import dataclass +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.mixins import SettingsMixin + +PartData = TypeVar('PartData') + + +class SupplierMixin(SettingsMixin, Generic[PartData]): + """Mixin which provides integration to specific suppliers.""" + + SUPPLIER_NAME: str + + 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(self): + """Return the supplier company object.""" + return company.models.Company.objects.get( + pk=self.get_setting('SUPPLIER', cache=True) + ) + + @dataclass + class SearchResult: + """Data class to represent a search result from a supplier.""" + + 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.""" + + 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.""" + + # --- Methods to be overridden by plugins --- + def get_search_results(self, term: str) -> list[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, 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[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 SupplierMixin.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 + 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 000000000000..ad7200916104 --- /dev/null +++ b/src/backend/InvenTree/plugin/base/supplier/serializers.py @@ -0,0 +1,106 @@ +"""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 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.""" + + 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 cb137237c39a..f918a46e54c9 100644 --- a/src/backend/InvenTree/plugin/mixins/__init__.py +++ b/src/backend/InvenTree/plugin/mixins/__init__.py @@ -17,6 +17,7 @@ from plugin.base.integration.ValidationMixin import ValidationMixin from plugin.base.label.mixins import LabelPrintingMixin from plugin.base.locate.mixins import LocateMixin +from plugin.base.supplier.mixins import SupplierMixin from plugin.base.ui.mixins import UserInterfaceMixin __all__ = [ @@ -37,6 +38,7 @@ 'SettingsMixin', 'SingleNotificationMethod', 'SupplierBarcodeMixin', + 'SupplierMixin', 'UrlsMixin', 'UserInterfaceMixin', 'ValidationMixin', diff --git a/src/backend/InvenTree/plugin/plugin.py b/src/backend/InvenTree/plugin/plugin.py index f3c8f02f1ec2..2395f701363d 100644 --- a/src/backend/InvenTree/plugin/plugin.py +++ b/src/backend/InvenTree/plugin/plugin.py @@ -41,6 +41,7 @@ class PluginMixinEnum(StringEnum): SCHEDULE = 'schedule' SETTINGS = 'settings' SETTINGS_CONTENT = 'settingscontent' + SUPPLIER = 'supplier' SUPPLIER_BARCODE = 'supplier-barcode' URLS = 'urls' USER_INTERFACE = 'ui' 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 000000000000..e69de29bb2d1 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 000000000000..0a7c41bb2b28 --- /dev/null +++ b/src/backend/InvenTree/plugin/samples/supplier/supplier_sample.py @@ -0,0 +1,174 @@ +"""Sample supplier plugin.""" + +from company.models import Company, ManufacturerPart, SupplierPart, SupplierPriceBreak +from part.models import Part +from plugin.base.supplier.mixins import SupplierMixin +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' + + SUPPLIER_NAME = 'Sample Supplier' + + 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://demo.inventree.org/media/part_images/flat-head.png', + 'brand': 'Bolt Manufacturer', + }) + + def get_search_results(self, term: str) -> list[SupplierMixin.SearchResult]: + """Return a list of search results based on the search term.""" + return [ + SupplierMixin.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, 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 SupplierMixin.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[SupplierMixin.ImportParameter]: + """Return a list of parameters for the given part data.""" + return [ + SupplierMixin.ImportParameter(name='Thread', value=data['thread'][1:]), + SupplierMixin.ImportParameter(name='Length', value=f'{data["length"]}mm'), + SupplierMixin.ImportParameter(name='Material', value=data['material']), + SupplierMixin.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, + }, + ) + part.variant_of = parent_part + 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, + **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 000000000000..19083e3d9f8b --- /dev/null +++ b/src/backend/InvenTree/plugin/samples/supplier/test_supplier_sample.py @@ -0,0 +1,103 @@ +"""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 +from plugin import registry + + +class SampleSupplierTest(InvenTreeAPITestCase): + """Tests for SampleSupplierPlugin.""" + + fixtures = ['location', 'category', 'part', 'stock', 'company'] + roles = ['part.add'] + + def test_search(self): + """Check if the event is issued.""" + # Activate plugin + config = registry.get_plugin('samplesupplier').plugin_config() + config.active = True + config.save() + + # Test APIs + url = reverse('api-supplier-search') + + # No supplier + self.get(url, {'supplier': 'non-existent-supplier'}, expected_code=404) + + # valid supplier + res = self.get( + url, {'supplier': 'samplesupplier', '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') + config = plugin.plugin_config() + config.active = True + config.save() + plugin.set_setting('SUPPLIER', 1) + + # Test APIs + url = reverse('api-supplier-import') + + # No supplier + self.post(url, {'supplier': 'non-existent-supplier'}, expected_code=400) + + # valid supplier, no part or category provided + self.post(url, {'supplier': 'samplesupplier'}, expected_code=400) + + # valid supplier, valid part import + res = self.post( + url, + { + 'supplier': 'samplesupplier', + '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']) + ) + + # valid supplier, import only manufacturer and supplier part + part2 = Part.objects.create(name='Test Part', purchaseable=True) + res = self.post( + url, + { + 'supplier': 'samplesupplier', + '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, + { + 'supplier': 'samplesupplier', + '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 96f08d422403..251e6c4e75eb 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -109,6 +109,7 @@ export enum ApiEndpoints { part_list = 'part/', part_parameter_list = 'part/parameter/', part_parameter_template_list = 'part/parameter/template/', + part_parameter_bulk = 'part/parameter/bulk/', part_thumbs_list = 'part/thumbs/', part_pricing = 'part/:id/pricing/', part_serial_numbers = 'part/:id/serial-numbers/', @@ -218,6 +219,8 @@ export enum ApiEndpoints { // Special plugin endpoints plugin_locate_item = 'locate/', + 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 01d409a2cfe7..043bdf54fcdb 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -114,6 +114,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 000000000000..d721fbaa208b --- /dev/null +++ b/src/frontend/src/components/wizards/ImportPartWizard.tsx @@ -0,0 +1,747 @@ +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 { + 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 { usePluginsWithMixin } from '../../hooks/UsePlugins'; +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.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: { + supplier: string; + searchResult: SearchResult; + }) => void; + partId?: number; +}) => { + const [searchValue, setSearchValue] = useState(''); + const [supplier, setSupplier] = useState(''); + const supplierPlugins = usePluginsWithMixin('supplier'); + const [searchResults, setSearchResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const handleSearch = useCallback(async () => { + setIsLoading(true); + const res = await api.get(apiUrl(ApiEndpoints.plugin_supplier_search), { + params: { + supplier, + term: searchValue + } + }); + setSearchResults(res.data ?? []); + setIsLoading(false); + }, [supplier, searchValue]); + + useEffect(() => { + if (supplier === '' && supplierPlugins.length > 0) { + setSupplier(supplierPlugins[0].meta.slug ?? ''); + } + }, [supplierPlugins]); + + return ( + + + setSearchValue(event.currentTarget.value)} + /> +