2
0
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:
Lukas Wolf
2025-10-17 22:13:03 +02:00
committed by GitHub
parent d534f67c62
commit de270a5fe7
41 changed files with 2298 additions and 119 deletions

View File

@@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

28
docs/docs/part/import.md Normal file
View 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") }}

View 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: []

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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})

View File

@@ -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)),
]

View 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'),
]

View 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."""

View 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

View 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']

View File

@@ -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',
]

View File

@@ -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'

View 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

View File

@@ -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,
)

View File

@@ -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/',

View File

@@ -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,

View 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;
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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
View 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}`)}`
}
});

View File

@@ -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);
}
};

View File

@@ -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
});

View File

@@ -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');
});

View File

@@ -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
});

View File

@@ -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',

View File

@@ -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
});

View File

@@ -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
});

View File

@@ -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
});
}

View File

@@ -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);
};