mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-22 00:47:38 +00:00
Supplier Mixin (#9761)
* commit initial draft for supplier import * complete import wizard * allow importing only mp and sp * improved sample supplier plugin * add docs * add tests * bump api version * fix schema docu * fix issues from code review * commit unstaged changes * fix test * refactor part parameter bulk creation * try to fix test * fix tests * fix test for mysql * fix test * support multiple suppliers by a single plugin * hide import button if there is no supplier import plugin * make form submitable via enter * add pui test * try to prevent race condition * refactor api calls in pui tests * try to fix tests again? * fix tests * trigger: ci * update changelog * fix api_version * fix style * Update CHANGELOG.md Co-authored-by: Matthias Mair <code@mjmair.com> * add user docs --------- Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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})
|
||||
|
@@ -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)),
|
||||
]
|
||||
|
246
src/backend/InvenTree/plugin/base/supplier/api.py
Normal file
246
src/backend/InvenTree/plugin/base/supplier/api.py
Normal file
@@ -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'),
|
||||
]
|
88
src/backend/InvenTree/plugin/base/supplier/helpers.py
Normal file
88
src/backend/InvenTree/plugin/base/supplier/helpers.py
Normal file
@@ -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."""
|
177
src/backend/InvenTree/plugin/base/supplier/mixins.py
Normal file
177
src/backend/InvenTree/plugin/base/supplier/mixins.py
Normal file
@@ -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
|
115
src/backend/InvenTree/plugin/base/supplier/serializers.py
Normal file
115
src/backend/InvenTree/plugin/base/supplier/serializers.py
Normal file
@@ -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']
|
@@ -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',
|
||||
]
|
||||
|
@@ -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'
|
||||
|
182
src/backend/InvenTree/plugin/samples/supplier/supplier_sample.py
Normal file
182
src/backend/InvenTree/plugin/samples/supplier/supplier_sample.py
Normal file
@@ -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
|
@@ -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,
|
||||
)
|
@@ -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/',
|
||||
|
@@ -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,
|
||||
|
811
src/frontend/src/components/wizards/ImportPartWizard.tsx
Normal file
811
src/frontend/src/components/wizards/ImportPartWizard.tsx
Normal file
@@ -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 (
|
||||
<Paper key={searchResult.id} withBorder p='md' shadow='xs'>
|
||||
<Group justify='space-between' align='flex-start' gap='xs'>
|
||||
{searchResult.image_url && (
|
||||
<img
|
||||
src={searchResult.image_url}
|
||||
alt={searchResult.name}
|
||||
style={{ maxHeight: '50px' }}
|
||||
/>
|
||||
)}
|
||||
<Stack gap={0} flex={1}>
|
||||
<a href={searchResult.link} target='_blank' rel='noopener noreferrer'>
|
||||
<Text size='lg' w={500}>
|
||||
{searchResult.name} ({searchResult.sku})
|
||||
</Text>
|
||||
</a>
|
||||
<Text size='sm'>{searchResult.description}</Text>
|
||||
</Stack>
|
||||
<Group gap='xs'>
|
||||
{searchResult.price && (
|
||||
<Text size='sm' c='primary'>
|
||||
{searchResult.price}
|
||||
</Text>
|
||||
)}
|
||||
{searchResult.exact && (
|
||||
<Badge size='sm' color='green'>
|
||||
<Trans>Exact Match</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{searchResult.existing_part_id &&
|
||||
partId &&
|
||||
searchResult.existing_part_id === partId && (
|
||||
<Badge size='sm' color='orange'>
|
||||
<Trans>Current part</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{searchResult.existing_part_id && (
|
||||
<Link to={`/part/${searchResult.existing_part_id}`}>
|
||||
<Badge size='sm' color='blue'>
|
||||
<Trans>Already Imported</Trans>
|
||||
</Badge>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{rightSection}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
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<SearchResult[]>([]);
|
||||
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<FormEventHandler<HTMLFormElement>>(
|
||||
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 (
|
||||
<Stack>
|
||||
<form onSubmit={handleSearch}>
|
||||
<Group align='flex-end'>
|
||||
<TextInput
|
||||
aria-label='textbox-search-for-part'
|
||||
flex={1}
|
||||
placeholder='Search for a part'
|
||||
label={t`Search...`}
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
label={t`Supplier`}
|
||||
value={supplier}
|
||||
onChange={(value) => setSupplier(value ?? '')}
|
||||
data={
|
||||
supplierQuery.data?.map((supplier) => ({
|
||||
value: JSON.stringify([
|
||||
supplier.plugin_slug,
|
||||
supplier.supplier_slug
|
||||
]),
|
||||
label: supplier.supplier_name
|
||||
})) || []
|
||||
}
|
||||
searchable
|
||||
disabled={supplierQuery.isLoading || supplierQuery.isError}
|
||||
placeholder={
|
||||
supplierQuery.isLoading
|
||||
? t`Loading...`
|
||||
: supplierQuery.isError
|
||||
? t`Error fetching suppliers`
|
||||
: t`Select supplier`
|
||||
}
|
||||
/>
|
||||
<Button disabled={!searchValue || !supplier} type='submit'>
|
||||
<Trans>Search</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
{isLoading && (
|
||||
<Center>
|
||||
<Loader />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<Text size='sm' c='dimmed'>
|
||||
<Trans>Found {searchResults.length} results</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<ScrollAreaAutosize style={{ maxHeight: '49vh' }}>
|
||||
<Stack gap='xs'>
|
||||
{searchResults.map((res) => (
|
||||
<SearchResult
|
||||
key={res.id}
|
||||
searchResult={res}
|
||||
partId={partId}
|
||||
rightSection={
|
||||
!res.existing_part_id && (
|
||||
<Tooltip label={t`Import this part`}>
|
||||
<ActionIcon
|
||||
aria-label={`action-button-import-part-${res.id}`}
|
||||
onClick={() => {
|
||||
const [plugin_slug, supplier_slug] =
|
||||
JSON.parse(supplier);
|
||||
|
||||
selectSupplierPart({
|
||||
plugin: plugin_slug,
|
||||
supplier: supplier_slug,
|
||||
searchResult: res
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconArrowDown size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollAreaAutosize>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const CategoryStep = ({
|
||||
categoryId,
|
||||
importPart,
|
||||
isImporting
|
||||
}: {
|
||||
isImporting: boolean;
|
||||
categoryId?: number;
|
||||
importPart: (categoryId: number) => void;
|
||||
}) => {
|
||||
const [category, setCategory] = useState<number | undefined>(categoryId);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<StandaloneField
|
||||
fieldDefinition={{
|
||||
field_type: 'related field',
|
||||
api_url: apiUrl(ApiEndpoints.category_list),
|
||||
description: '',
|
||||
label: t`Select category`,
|
||||
model: ModelType.partcategory,
|
||||
filters: { structural: false },
|
||||
value: category,
|
||||
onValueChange: (value) => setCategory(value)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text>
|
||||
<Trans>
|
||||
Are you sure you want to import this part into the selected category
|
||||
now?
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Group justify='flex-end'>
|
||||
<Button
|
||||
aria-label='action-button-import-part-now'
|
||||
disabled={!category || isImporting}
|
||||
onClick={() => importPart(category!)}
|
||||
loading={isImporting}
|
||||
>
|
||||
<Trans>Import Now</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type ParametersType = (ImportResult['parameters'][number] & { use: boolean })[];
|
||||
|
||||
const ParametersStep = ({
|
||||
importResult,
|
||||
isImporting,
|
||||
skipStep,
|
||||
importParameters,
|
||||
parameterErrors
|
||||
}: {
|
||||
importResult: ImportResult;
|
||||
isImporting: boolean;
|
||||
skipStep: () => void;
|
||||
importParameters: (parameters: ParametersType) => Promise<void>;
|
||||
parameterErrors: { template?: string; data?: string }[] | null;
|
||||
}) => {
|
||||
const [parameters, setParameters] = useState<ParametersType>(() =>
|
||||
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 (
|
||||
<Stack>
|
||||
<Text size='sm'>
|
||||
<Trans>
|
||||
Select and edit the parameters you want to add to this part.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{parametersFromCategory > 0 && (
|
||||
<Title order={5}>
|
||||
<Trans>Default category parameters</Trans>
|
||||
<Badge ml='xs'>{categoryCount}</Badge>
|
||||
</Title>
|
||||
)}
|
||||
<Stack gap='xs'>
|
||||
{parameters.map((p, i) => (
|
||||
<Stack key={i}>
|
||||
{p.on_category === false &&
|
||||
parameters[i - 1]?.on_category === true && (
|
||||
<>
|
||||
<Divider />
|
||||
<Title order={5}>
|
||||
<Trans>Other parameters</Trans>
|
||||
<Badge ml='xs'>{otherCount}</Badge>
|
||||
</Title>
|
||||
</>
|
||||
)}
|
||||
<Group align='center' gap='xs'>
|
||||
<Checkbox
|
||||
checked={p.use}
|
||||
onChange={(e) =>
|
||||
setParameter(i, 'use')(e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
{!p.on_category && (
|
||||
<Tooltip label={p.name}>
|
||||
<Text
|
||||
w='160px'
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Box flex={1}>
|
||||
<StandaloneField
|
||||
hideLabels
|
||||
fieldDefinition={{
|
||||
field_type: 'related field',
|
||||
model: ModelType.partparametertemplate,
|
||||
api_url: apiUrl(ApiEndpoints.part_parameter_template_list),
|
||||
disabled: p.on_category,
|
||||
value: p.parameter_template,
|
||||
onValueChange: (v) => {
|
||||
if (!p.parameter_template) setParameter(i, 'use')(true);
|
||||
setParameter(i, 'parameter_template')(v);
|
||||
},
|
||||
error: parameterErrors?.[i]?.template
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<TextInput
|
||||
flex={1}
|
||||
value={p.value}
|
||||
onChange={(e) =>
|
||||
setParameter(i, 'value')(e.currentTarget.value)
|
||||
}
|
||||
error={parameterErrors?.[i]?.data}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
<Tooltip label={t`Add a new parameter`}>
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
setParameters((p) => [
|
||||
...p,
|
||||
{
|
||||
name: '',
|
||||
value: '',
|
||||
parameter_template: null,
|
||||
on_category: false,
|
||||
use: true
|
||||
}
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<IconPlus size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<Group justify='flex-end'>
|
||||
<Button onClick={skipStep}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label='action-button-import-create-parameters'
|
||||
disabled={isImporting || parameters.filter((p) => p.use).length === 0}
|
||||
loading={isImporting}
|
||||
onClick={() => importParameters(parameters)}
|
||||
>
|
||||
<Trans>Create Parameters</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const StockStep = ({
|
||||
importResult,
|
||||
nextStep
|
||||
}: {
|
||||
importResult: ImportResult;
|
||||
nextStep: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<Stack>
|
||||
<Text size='sm'>
|
||||
<Trans>Create initial stock for the imported part.</Trans>
|
||||
</Text>
|
||||
|
||||
<StockItemTable
|
||||
tableName='initial-stock-creation'
|
||||
allowAdd
|
||||
showPricing
|
||||
showLocation
|
||||
params={{
|
||||
part: importResult.part_id,
|
||||
supplier_part: importResult.supplier_part_id,
|
||||
pricing: importResult.pricing,
|
||||
openNewStockItem: false
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group justify='flex-end'>
|
||||
<Button onClick={nextStep} aria-label='action-button-import-stock-next'>
|
||||
<Trans>Next</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ImportPartWizard({
|
||||
categoryId,
|
||||
partId
|
||||
}: {
|
||||
categoryId?: number;
|
||||
partId?: number;
|
||||
}) {
|
||||
const [supplierPart, setSupplierPart] = useState<{
|
||||
plugin: string;
|
||||
supplier: string;
|
||||
searchResult: SearchResult;
|
||||
}>();
|
||||
const [importResult, setImportResult] = useState<ImportResult>();
|
||||
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 (
|
||||
<Stack gap='xs'>
|
||||
{editPart.modal}
|
||||
|
||||
{step > 0 && supplierPart && (
|
||||
<SearchResult
|
||||
searchResult={supplierPart?.searchResult}
|
||||
partId={partId}
|
||||
rightSection={
|
||||
importResult && (
|
||||
<Group gap='xs'>
|
||||
<Link to={`/part/${importResult.part_id}`} target='_blank'>
|
||||
<InvenTreeIcon icon='part' />
|
||||
</Link>
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
editPart.open();
|
||||
}}
|
||||
>
|
||||
<InvenTreeIcon icon='edit' />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 0 && (
|
||||
<SearchStep
|
||||
selectSupplierPart={(sp) => {
|
||||
setSupplierPart(sp);
|
||||
wizard.nextStep();
|
||||
}}
|
||||
partId={partId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!partId && step === 1 && (
|
||||
<CategoryStep
|
||||
isImporting={isImporting}
|
||||
categoryId={categoryId}
|
||||
importPart={(categoryId) => {
|
||||
importPart({ categoryId });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!partId && step === 1 && (
|
||||
<Stack>
|
||||
<RenderRemoteInstance model={ModelType.part} pk={partId} />
|
||||
|
||||
<Text>
|
||||
<Trans>
|
||||
Are you sure, you want to import the supplier and manufacturer
|
||||
part into this part?
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Group justify='flex-end'>
|
||||
<Button
|
||||
disabled={isImporting}
|
||||
onClick={() => {
|
||||
importPart({ partId });
|
||||
}}
|
||||
loading={isImporting}
|
||||
>
|
||||
<Trans>Import</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!partId && step === 2 && (
|
||||
<ParametersStep
|
||||
importResult={importResult!}
|
||||
isImporting={isImporting}
|
||||
parameterErrors={parameterErrors}
|
||||
importParameters={async (parameters) => {
|
||||
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<number, number>
|
||||
);
|
||||
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<string, string[]>) => {
|
||||
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) && (
|
||||
<StockStep
|
||||
importResult={importResult!}
|
||||
nextStep={() => wizard.nextStep()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === (!partId ? 4 : 3) && (
|
||||
<Stack>
|
||||
<Text size='sm'>
|
||||
<Trans>
|
||||
Part imported successfully from supplier{' '}
|
||||
{supplierPart?.supplier}.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Group justify='flex-end'>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/part/${importResult?.part_id}`}
|
||||
variant='light'
|
||||
aria-label='action-button-import-open-part'
|
||||
>
|
||||
<Trans>Open Part</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/purchasing/supplier-part/${importResult?.supplier_part_id}`}
|
||||
variant='light'
|
||||
>
|
||||
<Trans>Open Supplier Part</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/purchasing/manufacturer-part/${importResult?.manufacturer_part_id}`}
|
||||
variant='light'
|
||||
>
|
||||
<Trans>Open Manufacturer Part</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => wizard.closeWizard()}
|
||||
aria-label='action-button-import-close'
|
||||
>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
[
|
||||
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;
|
||||
}
|
@@ -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 (
|
||||
<Card p='xs' withBorder>
|
||||
<Group justify='space-between' gap='xs' wrap='nowrap'>
|
||||
<Tooltip
|
||||
label={steps[currentStep - 1]}
|
||||
position='top'
|
||||
disabled={!canStepBackward}
|
||||
>
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
onClick={() => onSelectStep(currentStep - 1)}
|
||||
<Group
|
||||
justify={disableManualStepChange ? 'center' : 'space-between'}
|
||||
gap='xs'
|
||||
wrap='nowrap'
|
||||
>
|
||||
{!disableManualStepChange && (
|
||||
<Tooltip
|
||||
label={steps[currentStep - 1]}
|
||||
position='top'
|
||||
disabled={!canStepBackward}
|
||||
>
|
||||
<IconArrowLeft />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
onClick={() => onSelectStep(currentStep - 1)}
|
||||
disabled={!canStepBackward}
|
||||
>
|
||||
<IconArrowLeft />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Stepper
|
||||
active={currentStep}
|
||||
onStepClick={(stepIndex: number) => onSelectStep(stepIndex)}
|
||||
onStepClick={(stepIndex: number) => {
|
||||
if (disableManualStepChange) return;
|
||||
onSelectStep(stepIndex);
|
||||
}}
|
||||
iconSize={20}
|
||||
size='xs'
|
||||
>
|
||||
@@ -84,19 +94,21 @@ function WizardProgressStepper({
|
||||
))}
|
||||
</Stepper>
|
||||
{canStepForward ? (
|
||||
<Tooltip
|
||||
label={steps[currentStep + 1]}
|
||||
position='top'
|
||||
disabled={!canStepForward}
|
||||
>
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
onClick={() => onSelectStep(currentStep + 1)}
|
||||
!disableManualStepChange && (
|
||||
<Tooltip
|
||||
label={steps[currentStep + 1]}
|
||||
position='top'
|
||||
disabled={!canStepForward}
|
||||
>
|
||||
<IconArrowRight />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
onClick={() => onSelectStep(currentStep + 1)}
|
||||
disabled={!canStepForward || disableManualStepChange}
|
||||
>
|
||||
<IconArrowRight />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)
|
||||
) : (
|
||||
<Tooltip label={t`Complete`} position='top'>
|
||||
<ActionIcon color='green' variant='transparent'>
|
||||
@@ -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({
|
||||
<WizardProgressStepper
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
disableManualStepChange={disableManualStepChange}
|
||||
onSelectStep={(step: number) => {
|
||||
if (disableManualStepChange) return;
|
||||
if (step < currentStep) {
|
||||
onPreviousStep?.();
|
||||
} else {
|
||||
@@ -179,10 +195,7 @@ export default function WizardDrawer({
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Boundary label='wizard-drawer'>
|
||||
<Paper p='md'>{}</Paper>
|
||||
{children}
|
||||
</Boundary>
|
||||
<Boundary label='wizard-drawer'>{children}</Boundary>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
@@ -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<any>({});
|
||||
|
||||
const [supplierPart, setSupplierPart] = useState<number | null>(null);
|
||||
const [supplierPart, setSupplierPart] = useState<number | null>(
|
||||
supplierPartId ?? null
|
||||
);
|
||||
|
||||
const [expiryDate, setExpiryDate] = useState<string | null>(null);
|
||||
const [quantity, setQuantity] = useState<number | null>(null);
|
||||
const [purchasePrice, setPurchasePrice] = useState<number | null>(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: <IconCurrencyDollar />
|
||||
icon: <IconCurrencyDollar />,
|
||||
value: purchasePrice,
|
||||
onValueChange: (value) => {
|
||||
setPurchasePrice(value);
|
||||
}
|
||||
},
|
||||
purchase_price_currency: {
|
||||
icon: <IconCoins />
|
||||
icon: <IconCoins />,
|
||||
value: purchasePriceCurrency,
|
||||
onValueChange: (value) => {
|
||||
setPurchasePriceCurrency(value);
|
||||
}
|
||||
},
|
||||
packaging: {
|
||||
icon: <IconPackage />
|
||||
@@ -240,6 +280,10 @@ export function useStockFields({
|
||||
partId,
|
||||
globalSettings,
|
||||
supplierPart,
|
||||
create,
|
||||
supplierPartId,
|
||||
purchasePrice,
|
||||
purchasePriceCurrency,
|
||||
serialGenerator.result,
|
||||
batchGenerator.result,
|
||||
create
|
||||
|
@@ -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: (
|
||||
<WizardDrawer
|
||||
disableManualStepChange={props.disableManualStepChange}
|
||||
title={props.title}
|
||||
currentStep={currentStep}
|
||||
steps={props.steps}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Group, Text } from '@mantine/core';
|
||||
import { IconShoppingCart } from '@tabler/icons-react';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { ActionButton } from '@lib/components/ActionButton';
|
||||
import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
import {
|
||||
type RowAction,
|
||||
@@ -17,7 +17,9 @@ import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import type { InvenTreeTableProps } from '@lib/types/Tables';
|
||||
import { IconPackageImport, IconShoppingCart } from '@tabler/icons-react';
|
||||
import { ActionDropdown } from '../../components/items/ActionDropdown';
|
||||
import ImportPartWizard from '../../components/wizards/ImportPartWizard';
|
||||
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
||||
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
|
||||
import { usePartFields } from '../../forms/PartForms';
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
useCreateApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { usePluginsWithMixin } from '../../hooks/UsePlugins';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@@ -424,6 +427,11 @@ export function PartListTable({
|
||||
|
||||
const orderPartsWizard = OrderPartsWizard({ parts: table.selectedRecords });
|
||||
|
||||
const supplierPlugins = usePluginsWithMixin('supplier');
|
||||
const importPartWizard = ImportPartWizard({
|
||||
categoryId: initialPartData.category
|
||||
});
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
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()}
|
||||
/>,
|
||||
<ActionButton
|
||||
key='import-part'
|
||||
hidden={
|
||||
supplierPlugins.length === 0 || !user.hasAddRole(UserRoles.part)
|
||||
}
|
||||
tooltip={t`Import Part`}
|
||||
color='green'
|
||||
icon={<IconPackageImport />}
|
||||
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}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.part_list)}
|
||||
tableState={table}
|
||||
|
@@ -2,6 +2,7 @@ import { t } from '@lingui/core/macro';
|
||||
import { Text } from '@mantine/core';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { ActionButton } from '@lib/components/ActionButton';
|
||||
import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
import {
|
||||
type RowAction,
|
||||
@@ -14,12 +15,15 @@ import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { IconPackageImport } from '@tabler/icons-react';
|
||||
import ImportPartWizard from '../../components/wizards/ImportPartWizard';
|
||||
import { useSupplierPartFields } from '../../forms/CompanyForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { usePluginsWithMixin } from '../../hooks/UsePlugins';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
@@ -161,6 +165,11 @@ export function SupplierPartTable({
|
||||
successMessage: t`Supplier part created`
|
||||
});
|
||||
|
||||
const supplierPlugins = usePluginsWithMixin('supplier');
|
||||
const importPartWizard = ImportPartWizard({
|
||||
partId: params?.part
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
@@ -168,9 +177,21 @@ export function SupplierPartTable({
|
||||
tooltip={t`Add supplier part`}
|
||||
onClick={() => addSupplierPart.open()}
|
||||
hidden={!user.hasAddRole(UserRoles.purchase_order)}
|
||||
/>,
|
||||
<ActionButton
|
||||
key='import-part'
|
||||
icon={<IconPackageImport />}
|
||||
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}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.supplier_part_list)}
|
||||
tableState={table}
|
||||
|
@@ -515,6 +515,8 @@ export function StockItemTable({
|
||||
const newStockItemFields = useStockFields({
|
||||
create: true,
|
||||
partId: params.part,
|
||||
supplierPartId: params.supplier_part,
|
||||
pricing: params.pricing,
|
||||
modalId: 'add-stock-item'
|
||||
});
|
||||
|
||||
@@ -527,7 +529,7 @@ export function StockItemTable({
|
||||
part: params.part,
|
||||
location: params.location
|
||||
},
|
||||
follow: true,
|
||||
follow: params.openNewStockItem ?? true,
|
||||
table: table,
|
||||
onFormSuccess: (response: any) => {
|
||||
// Returns a list that may contain multiple serialized stock items
|
||||
|
10
src/frontend/tests/api.ts
Normal file
10
src/frontend/tests/api.ts
Normal file
@@ -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}`)}`
|
||||
}
|
||||
});
|
@@ -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);
|
||||
}
|
||||
};
|
||||
|
@@ -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
|
||||
});
|
||||
|
@@ -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');
|
||||
});
|
||||
|
@@ -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
|
||||
});
|
||||
|
@@ -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',
|
||||
|
@@ -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
|
||||
});
|
||||
|
@@ -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
|
||||
});
|
||||
|
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
};
|
||||
|
Reference in New Issue
Block a user