mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-18 23:17:41 +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:
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- Added `order_queryset` report helper function in [#10439](https://github.com/inventree/InvenTree/pull/10439)
|
||||
- Added `SupplierMixin` to import data from suppliers in [#9761](https://github.com/inventree/InvenTree/pull/9761)
|
||||
- Added much more detailed status information for machines to the API endpoint (including backend and frontend changes) in [#10381](https://github.com/inventree/InvenTree/pull/10381)
|
||||
- Added ability to partially complete and partially scrap build outputs in [#10499](https://github.com/inventree/InvenTree/pull/10499)
|
||||
- Added support for Redis ACL user-based authentication in [#10551](https://github.com/inventree/InvenTree/pull/10551)
|
||||
|
BIN
docs/docs/assets/images/part/import_part.png
Normal file
BIN
docs/docs/assets/images/part/import_part.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 482 KiB |
BIN
docs/docs/assets/images/part/import_part_wizard.png
Normal file
BIN
docs/docs/assets/images/part/import_part_wizard.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 684 KiB |
BIN
docs/docs/assets/images/part/import_supplier_part.png
Normal file
BIN
docs/docs/assets/images/part/import_supplier_part.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 519 KiB |
28
docs/docs/part/import.md
Normal file
28
docs/docs/part/import.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: Importing Data from suppliers
|
||||
---
|
||||
|
||||
## Import data from suppliers
|
||||
|
||||
InvenTree can integrate with external suppliers and import data from them, which helps to setup your system. Currently parts, supplier parts and manufacturer parts can be created automatically.
|
||||
|
||||
### Requirements
|
||||
|
||||
1. Install a supplier mixin plugin for you supplier
|
||||
2. Goto "Admin Center > Plugins > [The supplier plugin]" and set the supplier company setting. Some plugins may require additional settings like API tokens.
|
||||
|
||||
### Import a part
|
||||
|
||||
New parts can be imported from the _Part Category_ view, by pressing the _Import Part_ button:
|
||||
|
||||
{{ image("part/import_part.png", "Import part") }}
|
||||
|
||||
Then just follow the wizard to confirm the category, select the parameters and create initial stock.
|
||||
|
||||
{{ image("part/import_part_wizard.png", "Import part wizard") }}
|
||||
|
||||
### Import a supplier part
|
||||
|
||||
If you already have the part created, you can also just import the supplier part with it's corresponding manufacturer part. Open the supplier panel for the part and use the "Import supplier part" button:
|
||||
|
||||
{{ image("part/import_supplier_part.png", "Import supplier part") }}
|
48
docs/docs/plugins/mixins/supplier.md
Normal file
48
docs/docs/plugins/mixins/supplier.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Supplier Mixin
|
||||
---
|
||||
|
||||
## SupplierMixin
|
||||
|
||||
The `SupplierMixin` class enables plugins to integrate with external suppliers, enabling seamless creation of parts, supplier parts, and manufacturer parts with just a few clicks from the supplier. The import process is split into multiple phases:
|
||||
|
||||
- Search supplier
|
||||
- Select InvenTree category
|
||||
- Match Part Parameters
|
||||
- Create initial Stock
|
||||
|
||||
### Import Methods
|
||||
|
||||
A plugin can connect to multiple suppliers. The `get_suppliers` method should return a list of available supplier connections (e.g. using different credentials).
|
||||
When a user initiates a search through the UI, the `get_search_results` function is called with the search term, supplier slug returned previously, and the search results are returned. These contain a `part_id` which is then passed to `get_import_data` along with the `supplier_slug`, if a user decides to import that specific part. This function should return a bunch of data that is needed for the import process. This data may be cached in the future for the same `part_id`. Then depending if the user only wants to import the supplier and manufacturer part or the whole part, the `import_part`, `import_manufacturer_part` and `import_supplier_part` methods are called automatically. If the user has imported the complete part, the `get_parameters` method is used to get a list of parameters which then can be match to inventree part parameter templates with some provided guidance. Additionally the `get_pricing_data` method is used to extract price breaks which are automatically considered when creating initial stock through the UI in the part import wizard.
|
||||
|
||||
For that to work, a few methods need to be overridden:
|
||||
|
||||
::: plugin.base.supplier.mixins.SupplierMixin
|
||||
options:
|
||||
show_bases: False
|
||||
show_root_heading: False
|
||||
show_root_toc_entry: False
|
||||
summary: False
|
||||
members:
|
||||
- get_search_results
|
||||
- get_import_data
|
||||
- get_pricing_data
|
||||
- get_parameters
|
||||
- import_part
|
||||
- import_manufacturer_part
|
||||
- import_supplier_part
|
||||
extra:
|
||||
show_sources: True
|
||||
|
||||
### Sample Plugin
|
||||
|
||||
A simple example is provided in the InvenTree code base. Note that this uses some static data, but this can be extended in a real world plugin to e.g. call the supplier's API:
|
||||
|
||||
::: plugin.samples.supplier.supplier_sample.SampleSupplierPlugin
|
||||
options:
|
||||
show_bases: False
|
||||
show_root_heading: False
|
||||
show_root_toc_entry: False
|
||||
show_source: True
|
||||
members: []
|
@@ -146,6 +146,7 @@ nav:
|
||||
- Parts:
|
||||
- Parts: part/index.md
|
||||
- Creating Parts: part/create.md
|
||||
- Importing Parts: part/import.md
|
||||
- Virtual Parts: part/virtual.md
|
||||
- Part Views: part/views.md
|
||||
- Tracking: part/trackable.md
|
||||
@@ -237,6 +238,7 @@ nav:
|
||||
- Report Mixin: plugins/mixins/report.md
|
||||
- Schedule Mixin: plugins/mixins/schedule.md
|
||||
- Settings Mixin: plugins/mixins/settings.md
|
||||
- Supplier Mixin: plugins/mixins/supplier.md
|
||||
- Transition Mixin: plugins/mixins/transition.md
|
||||
- URL Mixin: plugins/mixins/urls.md
|
||||
- User Interface Mixin: plugins/mixins/ui.md
|
||||
|
@@ -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