mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-17 09:48:30 +00:00
Supplier Mixin (#9761)
* commit initial draft for supplier import * complete import wizard * allow importing only mp and sp * improved sample supplier plugin * add docs * add tests * bump api version * fix schema docu * fix issues from code review * commit unstaged changes * fix test * refactor part parameter bulk creation * try to fix test * fix tests * fix test for mysql * fix test * support multiple suppliers by a single plugin * hide import button if there is no supplier import plugin * make form submitable via enter * add pui test * try to prevent race condition * refactor api calls in pui tests * try to fix tests again? * fix tests * trigger: ci * update changelog * fix api_version * fix style * Update CHANGELOG.md Co-authored-by: Matthias Mair <code@mjmair.com> * add user docs --------- Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""Main JSON interface views."""
|
||||
|
||||
import collections
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -488,16 +489,46 @@ class BulkCreateMixin:
|
||||
|
||||
if isinstance(data, list):
|
||||
created_items = []
|
||||
errors = []
|
||||
has_errors = False
|
||||
|
||||
# If data is a list, we assume it is a bulk create request
|
||||
if len(data) == 0:
|
||||
raise ValidationError({'non_field_errors': _('No data provided')})
|
||||
|
||||
for item in data:
|
||||
serializer = self.get_serializer(data=item)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
created_items.append(serializer.data)
|
||||
# validate unique together fields
|
||||
if unique_create_fields := getattr(self, 'unique_create_fields', None):
|
||||
existing = collections.defaultdict(list)
|
||||
for idx, item in enumerate(data):
|
||||
key = tuple(item[v] for v in unique_create_fields)
|
||||
existing[key].append(idx)
|
||||
|
||||
unique_errors = [[] for _ in range(len(data))]
|
||||
has_unique_errors = False
|
||||
for item in existing.values():
|
||||
if len(item) > 1:
|
||||
has_unique_errors = True
|
||||
error = {}
|
||||
for field_name in unique_create_fields:
|
||||
error[field_name] = [_('This field must be unique.')]
|
||||
for idx in item:
|
||||
unique_errors[idx] = error
|
||||
if has_unique_errors:
|
||||
raise ValidationError(unique_errors)
|
||||
|
||||
with transaction.atomic():
|
||||
for item in data:
|
||||
serializer = self.get_serializer(data=item)
|
||||
if serializer.is_valid():
|
||||
self.perform_create(serializer)
|
||||
created_items.append(serializer.data)
|
||||
errors.append([])
|
||||
else:
|
||||
errors.append(serializer.errors)
|
||||
has_errors = True
|
||||
|
||||
if has_errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
return Response(created_items, status=201)
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 409
|
||||
INVENTREE_API_VERSION = 410
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v410 -> 2025-06-12 : https://github.com/inventree/InvenTree/pull/9761
|
||||
- Add supplier search and import API endpoints
|
||||
- Add part parameter bulk create API endpoint
|
||||
|
||||
v409 -> 2025-10-17 : https://github.com/inventree/InvenTree/pull/10601
|
||||
- Adds ability to filter StockList API by manufacturer part ID
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from rest_framework.response import Response
|
||||
import part.filters
|
||||
from data_exporter.mixins import DataExportViewMixin
|
||||
from InvenTree.api import (
|
||||
BulkCreateMixin,
|
||||
BulkDeleteMixin,
|
||||
BulkUpdateMixin,
|
||||
ListCreateDestroyAPIView,
|
||||
@@ -1416,7 +1417,11 @@ class PartParameterFilter(FilterSet):
|
||||
|
||||
|
||||
class PartParameterList(
|
||||
PartParameterAPIMixin, OutputOptionsMixin, DataExportViewMixin, ListCreateAPI
|
||||
BulkCreateMixin,
|
||||
PartParameterAPIMixin,
|
||||
OutputOptionsMixin,
|
||||
DataExportViewMixin,
|
||||
ListCreateAPI,
|
||||
):
|
||||
"""API endpoint for accessing a list of PartParameter objects.
|
||||
|
||||
@@ -1444,6 +1449,8 @@ class PartParameterList(
|
||||
'template__units',
|
||||
]
|
||||
|
||||
unique_create_fields = ['part', 'template']
|
||||
|
||||
|
||||
class PartParameterDetail(
|
||||
PartParameterAPIMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
|
||||
|
||||
@@ -364,6 +364,36 @@ class PartParameterTest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(len(response.data), 8)
|
||||
|
||||
def test_bulk_create_params(self):
|
||||
"""Test that we can bulk create parameters via the API."""
|
||||
url = reverse('api-part-parameter-list')
|
||||
part4 = Part.objects.get(pk=4)
|
||||
|
||||
data = [
|
||||
{'part': 4, 'template': 1, 'data': 70},
|
||||
{'part': 4, 'template': 2, 'data': 80},
|
||||
{'part': 4, 'template': 1, 'data': 80},
|
||||
]
|
||||
|
||||
# test that having non unique part/template combinations fails
|
||||
res = self.post(url, data, expected_code=400)
|
||||
self.assertEqual(len(res.data), 3)
|
||||
self.assertEqual(len(res.data[1]), 0)
|
||||
for err in [res.data[0], res.data[2]]:
|
||||
self.assertEqual(len(err), 2)
|
||||
self.assertEqual(str(err['part'][0]), 'This field must be unique.')
|
||||
self.assertEqual(str(err['template'][0]), 'This field must be unique.')
|
||||
self.assertEqual(PartParameter.objects.filter(part=part4).count(), 0)
|
||||
|
||||
# Now, create a valid set of parameters
|
||||
data = [
|
||||
{'part': 4, 'template': 1, 'data': 70},
|
||||
{'part': 4, 'template': 2, 'data': 80},
|
||||
]
|
||||
res = self.post(url, data, expected_code=201)
|
||||
self.assertEqual(len(res.data), 2)
|
||||
self.assertEqual(PartParameter.objects.filter(part=part4).count(), 2)
|
||||
|
||||
def test_param_detail(self):
|
||||
"""Tests for the PartParameter detail endpoint."""
|
||||
url = reverse('api-part-parameter-detail', kwargs={'pk': 5})
|
||||
|
||||
@@ -31,6 +31,7 @@ from InvenTree.mixins import (
|
||||
from plugin.base.action.api import ActionPluginView
|
||||
from plugin.base.barcodes.api import barcode_api_urls
|
||||
from plugin.base.locate.api import LocatePluginView
|
||||
from plugin.base.supplier.api import supplier_api_urls
|
||||
from plugin.base.ui.api import ui_plugins_api_urls
|
||||
from plugin.models import PluginConfig, PluginSetting, PluginUserSetting
|
||||
from plugin.plugin import InvenTreePlugin
|
||||
@@ -601,4 +602,5 @@ plugin_api_urls = [
|
||||
path('', PluginList.as_view(), name='api-plugin-list'),
|
||||
]),
|
||||
),
|
||||
path('supplier/', include(supplier_api_urls)),
|
||||
]
|
||||
|
||||
246
src/backend/InvenTree/plugin/base/supplier/api.py
Normal file
246
src/backend/InvenTree/plugin/base/supplier/api.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""API views for supplier plugins in InvenTree."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.db import transaction
|
||||
from django.urls import path
|
||||
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from InvenTree import permissions
|
||||
from part.models import PartCategoryParameterTemplate
|
||||
from plugin import registry
|
||||
from plugin.plugin import PluginMixinEnum
|
||||
|
||||
from .serializers import (
|
||||
ImportRequestSerializer,
|
||||
ImportResultSerializer,
|
||||
SearchResultSerializer,
|
||||
SupplierListSerializer,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from plugin.base.supplier.mixins import SupplierMixin
|
||||
else: # pragma: no cover
|
||||
|
||||
class SupplierMixin:
|
||||
"""Dummy class for type checking."""
|
||||
|
||||
|
||||
def get_supplier_plugin(plugin_slug: str, supplier_slug: str) -> SupplierMixin:
|
||||
"""Return the supplier plugin for the given plugin and supplier slugs."""
|
||||
supplier_plugin = None
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.SUPPLIER):
|
||||
if plugin.slug == plugin_slug:
|
||||
supplier_plugin = plugin
|
||||
break
|
||||
|
||||
if not supplier_plugin:
|
||||
raise NotFound(detail=f"Plugin '{plugin_slug}' not found")
|
||||
|
||||
if not any(s.slug == supplier_slug for s in supplier_plugin.get_suppliers()):
|
||||
raise NotFound(
|
||||
detail=f"Supplier '{supplier_slug}' not found for plugin '{plugin_slug}'"
|
||||
)
|
||||
|
||||
return supplier_plugin
|
||||
|
||||
|
||||
class ListSupplier(APIView):
|
||||
"""List all available supplier plugins.
|
||||
|
||||
- GET: List supplier plugins
|
||||
"""
|
||||
|
||||
role_required = 'part.add'
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticatedOrReadScope,
|
||||
permissions.RolePermission,
|
||||
]
|
||||
serializer_class = SupplierListSerializer
|
||||
|
||||
@extend_schema(responses={200: SupplierListSerializer(many=True)})
|
||||
def get(self, request):
|
||||
"""List all available supplier plugins."""
|
||||
suppliers = []
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.SUPPLIER):
|
||||
suppliers.extend([
|
||||
{
|
||||
'plugin_slug': plugin.slug,
|
||||
'supplier_slug': supplier.slug,
|
||||
'supplier_name': supplier.name,
|
||||
}
|
||||
for supplier in plugin.get_suppliers()
|
||||
])
|
||||
|
||||
return Response(suppliers)
|
||||
|
||||
|
||||
class SearchPart(APIView):
|
||||
"""Search parts by supplier.
|
||||
|
||||
- GET: Start part search
|
||||
"""
|
||||
|
||||
role_required = 'part.add'
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticatedOrReadScope,
|
||||
permissions.RolePermission,
|
||||
]
|
||||
serializer_class = SearchResultSerializer
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(name='plugin', description='Plugin slug', required=True),
|
||||
OpenApiParameter(
|
||||
name='supplier', description='Supplier slug', required=True
|
||||
),
|
||||
OpenApiParameter(name='term', description='Search term', required=True),
|
||||
],
|
||||
responses={200: SearchResultSerializer(many=True)},
|
||||
)
|
||||
def get(self, request):
|
||||
"""Search parts by supplier."""
|
||||
plugin_slug = request.query_params.get('plugin', '')
|
||||
supplier_slug = request.query_params.get('supplier', '')
|
||||
term = request.query_params.get('term', '')
|
||||
|
||||
supplier_plugin = get_supplier_plugin(plugin_slug, supplier_slug)
|
||||
try:
|
||||
results = supplier_plugin.get_search_results(supplier_slug, term)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
response = SearchResultSerializer(results, many=True).data
|
||||
return Response(response)
|
||||
|
||||
|
||||
class ImportPart(APIView):
|
||||
"""Import a part by supplier.
|
||||
|
||||
- POST: Attempt to import part by sku
|
||||
"""
|
||||
|
||||
role_required = 'part.add'
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticatedOrReadScope,
|
||||
permissions.RolePermission,
|
||||
]
|
||||
serializer_class = ImportResultSerializer
|
||||
|
||||
@extend_schema(
|
||||
request=ImportRequestSerializer, responses={200: ImportResultSerializer}
|
||||
)
|
||||
def post(self, request):
|
||||
"""Import a part by supplier."""
|
||||
serializer = ImportRequestSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Extract validated data
|
||||
plugin_slug = serializer.validated_data.get('plugin', '')
|
||||
supplier_slug = serializer.validated_data.get('supplier', '')
|
||||
part_import_id = serializer.validated_data.get('part_import_id', '')
|
||||
category = serializer.validated_data.get('category_id', None)
|
||||
part = serializer.validated_data.get('part_id', None)
|
||||
|
||||
supplier_plugin = get_supplier_plugin(plugin_slug, supplier_slug)
|
||||
|
||||
# Validate part/category
|
||||
if not part and not category:
|
||||
return Response(
|
||||
{
|
||||
'detail': "'category_id' is not provided, but required if no part_id is provided"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
from plugin.base.supplier.mixins import supplier
|
||||
|
||||
# Import part data
|
||||
try:
|
||||
import_data = supplier_plugin.get_import_data(supplier_slug, part_import_id)
|
||||
|
||||
with transaction.atomic():
|
||||
# create part if it does not exist
|
||||
if not part:
|
||||
part = supplier_plugin.import_part(
|
||||
import_data, category=category, creation_user=request.user
|
||||
)
|
||||
|
||||
# create manufacturer part
|
||||
manufacturer_part = supplier_plugin.import_manufacturer_part(
|
||||
import_data, part=part
|
||||
)
|
||||
|
||||
# create supplier part
|
||||
supplier_part = supplier_plugin.import_supplier_part(
|
||||
import_data, part=part, manufacturer_part=manufacturer_part
|
||||
)
|
||||
|
||||
# set default supplier if not set
|
||||
if not part.default_supplier:
|
||||
part.default_supplier = supplier_part
|
||||
part.save()
|
||||
|
||||
# get pricing
|
||||
pricing = supplier_plugin.get_pricing_data(import_data)
|
||||
|
||||
# get parameters
|
||||
parameters = supplier_plugin.get_parameters(import_data)
|
||||
except supplier.PartNotFoundError:
|
||||
return Response(
|
||||
{'detail': f"Part with id: '{part_import_id}' not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'detail': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
# add default parameters for category
|
||||
if category:
|
||||
categories = category.get_ancestors(include_self=True)
|
||||
category_parameters = PartCategoryParameterTemplate.objects.filter(
|
||||
category__in=categories
|
||||
)
|
||||
|
||||
for c in category_parameters:
|
||||
for p in parameters:
|
||||
if p.parameter_template == c.parameter_template:
|
||||
p.on_category = True
|
||||
p.value = p.value if p.value is not None else c.default_value
|
||||
break
|
||||
else:
|
||||
parameters.append(
|
||||
supplier.ImportParameter(
|
||||
name=c.parameter_template.name,
|
||||
value=c.default_value,
|
||||
on_category=True,
|
||||
parameter_template=c.parameter_template,
|
||||
)
|
||||
)
|
||||
parameters.sort(key=lambda x: x.on_category, reverse=True)
|
||||
|
||||
response = ImportResultSerializer({
|
||||
'part_id': part.pk,
|
||||
'part_detail': part,
|
||||
'supplier_part_id': supplier_part.pk,
|
||||
'manufacturer_part_id': manufacturer_part.pk,
|
||||
'pricing': pricing,
|
||||
'parameters': parameters,
|
||||
}).data
|
||||
return Response(response)
|
||||
|
||||
|
||||
supplier_api_urls = [
|
||||
path('list/', ListSupplier.as_view(), name='api-supplier-list'),
|
||||
path('search/', SearchPart.as_view(), name='api-supplier-search'),
|
||||
path('import/', ImportPart.as_view(), name='api-supplier-import'),
|
||||
]
|
||||
88
src/backend/InvenTree/plugin/base/supplier/helpers.py
Normal file
88
src/backend/InvenTree/plugin/base/supplier/helpers.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Dataclasses for supplier plugins."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import part.models as part_models
|
||||
|
||||
|
||||
@dataclass
|
||||
class Supplier:
|
||||
"""Data class to represent a supplier.
|
||||
|
||||
Note that one plugin can connect to multiple suppliers this way with e.g. different credentials.
|
||||
|
||||
Attributes:
|
||||
slug (str): A unique identifier for the supplier.
|
||||
name (str): The human-readable name of the supplier.
|
||||
"""
|
||||
|
||||
slug: str
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
"""Data class to represent a search result from a supplier.
|
||||
|
||||
Attributes:
|
||||
sku (str): The stock keeping unit identifier for the part.
|
||||
name (str): The name of the part.
|
||||
exact (bool): Indicates if the search result is an exact match.
|
||||
description (Optional[str]): A brief description of the part.
|
||||
price (Optional[str]): The price of the part as a string.
|
||||
link (Optional[str]): A URL link to the part on the supplier's website.
|
||||
image_url (Optional[str]): A URL to an image of the part.
|
||||
id (Optional[str]): An optional identifier for the part (part_id), defaults to sku if not provided
|
||||
existing_part (Optional[part_models.Part]): An existing part in the system that matches this search result, if any.
|
||||
"""
|
||||
|
||||
sku: str
|
||||
name: str
|
||||
exact: bool
|
||||
description: Optional[str] = None
|
||||
price: Optional[str] = None
|
||||
link: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
id: Optional[str] = None
|
||||
existing_part: Optional[part_models.Part] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Post-initialization to set the ID if not provided."""
|
||||
if not self.id:
|
||||
self.id = self.sku
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportParameter:
|
||||
"""Data class to represent a parameter for a part during import.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the parameter.
|
||||
value (str): The value of the parameter.
|
||||
on_category (Optional[bool]): Indicates if the parameter is associated with a category. This will be automatically set by InvenTree
|
||||
parameter_template (Optional[PartParameterTemplate]): The associated parameter template, if any.
|
||||
"""
|
||||
|
||||
name: str
|
||||
value: str
|
||||
on_category: Optional[bool] = False
|
||||
parameter_template: Optional[part_models.PartParameterTemplate] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Post-initialization to fetch the parameter template if not provided."""
|
||||
if not self.parameter_template:
|
||||
try:
|
||||
self.parameter_template = part_models.PartParameterTemplate.objects.get(
|
||||
name__iexact=self.name
|
||||
)
|
||||
except part_models.PartParameterTemplate.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class PartNotFoundError(Exception):
|
||||
"""Exception raised when a part is not found during import."""
|
||||
|
||||
|
||||
class PartImportError(Exception):
|
||||
"""Exception raised when an error occurs during part import."""
|
||||
177
src/backend/InvenTree/plugin/base/supplier/mixins.py
Normal file
177
src/backend/InvenTree/plugin/base/supplier/mixins.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Plugin mixin class for Supplier Integration."""
|
||||
|
||||
import io
|
||||
from typing import Any, Generic, Optional, TypeVar
|
||||
|
||||
import django.contrib.auth.models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
import company.models
|
||||
import part.models as part_models
|
||||
from InvenTree.helpers_model import download_image_from_url
|
||||
from plugin import PluginMixinEnum
|
||||
from plugin.base.supplier import helpers as supplier
|
||||
from plugin.mixins import SettingsMixin
|
||||
|
||||
PartData = TypeVar('PartData')
|
||||
|
||||
|
||||
class SupplierMixin(SettingsMixin, Generic[PartData]):
|
||||
"""Mixin which provides integration to specific suppliers."""
|
||||
|
||||
class MixinMeta:
|
||||
"""Meta options for this mixin."""
|
||||
|
||||
MIXIN_NAME = 'Supplier'
|
||||
|
||||
def __init__(self):
|
||||
"""Register mixin."""
|
||||
super().__init__()
|
||||
self.add_mixin(PluginMixinEnum.SUPPLIER, True, __class__)
|
||||
|
||||
self.SETTINGS['SUPPLIER'] = {
|
||||
'name': 'Supplier',
|
||||
'description': 'The Supplier which this plugin integrates with.',
|
||||
'model': 'company.company',
|
||||
'model_filters': {'is_supplier': True},
|
||||
'required': True,
|
||||
}
|
||||
|
||||
@property
|
||||
def supplier_company(self):
|
||||
"""Return the supplier company object."""
|
||||
pk = self.get_setting('SUPPLIER', cache=True)
|
||||
if not pk:
|
||||
raise supplier.PartImportError('Supplier setting is missing.')
|
||||
|
||||
return company.models.Company.objects.get(pk=pk)
|
||||
|
||||
# --- Methods to be overridden by plugins ---
|
||||
def get_suppliers(self) -> list[supplier.Supplier]:
|
||||
"""Return a list of available suppliers."""
|
||||
raise NotImplementedError('This method needs to be overridden.')
|
||||
|
||||
def get_search_results(
|
||||
self, supplier_slug: str, term: str
|
||||
) -> list[supplier.SearchResult]:
|
||||
"""Return a list of search results for the given search term."""
|
||||
raise NotImplementedError('This method needs to be overridden.')
|
||||
|
||||
def get_import_data(self, supplier_slug: str, part_id: str) -> PartData:
|
||||
"""Return the import data for the given part ID."""
|
||||
raise NotImplementedError('This method needs to be overridden.')
|
||||
|
||||
def get_pricing_data(self, data: PartData) -> dict[int, tuple[float, str]]:
|
||||
"""Return a dictionary of pricing data for the given part data."""
|
||||
raise NotImplementedError('This method needs to be overridden.')
|
||||
|
||||
def get_parameters(self, data: PartData) -> list[supplier.ImportParameter]:
|
||||
"""Return a list of parameters for the given part data."""
|
||||
raise NotImplementedError('This method needs to be overridden.')
|
||||
|
||||
def import_part(
|
||||
self,
|
||||
data: PartData,
|
||||
*,
|
||||
category: Optional[part_models.PartCategory],
|
||||
creation_user: Optional[django.contrib.auth.models.User],
|
||||
) -> part_models.Part:
|
||||
"""Import a part using the provided data.
|
||||
|
||||
This may include:
|
||||
- Creating a new part
|
||||
- Add an image to the part
|
||||
- if this part has several variants, (create) a template part and assign it to the part
|
||||
- create related parts
|
||||
- add attachments to the part
|
||||
"""
|
||||
raise NotImplementedError('This method needs to be overridden.')
|
||||
|
||||
def import_manufacturer_part(
|
||||
self, data: PartData, *, part: part_models.Part
|
||||
) -> company.models.ManufacturerPart:
|
||||
"""Import a manufacturer part using the provided data.
|
||||
|
||||
This may include:
|
||||
- Creating a new manufacturer
|
||||
- Creating a new manufacturer part
|
||||
- Assigning the part to the manufacturer part
|
||||
- Setting the default supplier for the part
|
||||
- Adding parameters to the manufacturer part
|
||||
- Adding attachments to the manufacturer part
|
||||
"""
|
||||
raise NotImplementedError('This method needs to be overridden.')
|
||||
|
||||
def import_supplier_part(
|
||||
self,
|
||||
data: PartData,
|
||||
*,
|
||||
part: part_models.Part,
|
||||
manufacturer_part: company.models.ManufacturerPart,
|
||||
) -> part_models.SupplierPart:
|
||||
"""Import a supplier part using the provided data.
|
||||
|
||||
This may include:
|
||||
- Creating a new supplier part
|
||||
- Creating supplier price breaks
|
||||
"""
|
||||
raise NotImplementedError('This method needs to be overridden.')
|
||||
|
||||
# --- Helper methods for importing parts ---
|
||||
def download_image(self, img_url: str):
|
||||
"""Download an image from the given URL and return it as a ContentFile."""
|
||||
img_r = download_image_from_url(img_url)
|
||||
fmt = img_r.format or 'PNG'
|
||||
buffer = io.BytesIO()
|
||||
img_r.save(buffer, format=fmt)
|
||||
|
||||
return ContentFile(buffer.getvalue()), fmt
|
||||
|
||||
def get_template_part(
|
||||
self, other_variants: list[part_models.Part], template_kwargs: dict[str, Any]
|
||||
) -> part_models.Part:
|
||||
"""Helper function to handle variant parts.
|
||||
|
||||
This helper function identifies all roots for the provided 'other_variants' list
|
||||
- for no root => root part will be created using the 'template_kwargs'
|
||||
- for one root
|
||||
- root is a template => return it
|
||||
- root is no template, create a new template like if there is no root
|
||||
and assign it to only root that was found and return it
|
||||
- for multiple roots => error raised
|
||||
"""
|
||||
root_set = {v.get_root() for v in other_variants}
|
||||
|
||||
# check how much roots for the variant parts exist to identify the parent_part
|
||||
parent_part = None # part that should be used as parent_part
|
||||
root_part = None # part that was discovered as root part in root_set
|
||||
if len(root_set) == 1:
|
||||
root_part = next(iter(root_set))
|
||||
if root_part.is_template:
|
||||
parent_part = root_part
|
||||
|
||||
if len(root_set) == 0 or (root_part and not root_part.is_template):
|
||||
parent_part = part_models.Part.objects.create(**template_kwargs)
|
||||
|
||||
if not parent_part:
|
||||
raise supplier.PartImportError(
|
||||
f'A few variant parts from the supplier are already imported, but have different InvenTree variant root parts, try to merge them to the same root variant template part (parts: {", ".join(str(p.pk) for p in other_variants)}).'
|
||||
)
|
||||
|
||||
# assign parent_part to root_part if root_part has no variant of already
|
||||
if root_part and not root_part.is_template and not root_part.variant_of:
|
||||
root_part.variant_of = parent_part # type: ignore
|
||||
root_part.save()
|
||||
|
||||
return parent_part
|
||||
|
||||
def create_related_parts(
|
||||
self, part: part_models.Part, related_parts: list[part_models.Part]
|
||||
):
|
||||
"""Create relationships between the given part and related parts."""
|
||||
for p in related_parts:
|
||||
try:
|
||||
part_models.PartRelated.objects.create(part_1=part, part_2=p)
|
||||
except ValidationError:
|
||||
pass # pass, duplicate relationship detected
|
||||
115
src/backend/InvenTree/plugin/base/supplier/serializers.py
Normal file
115
src/backend/InvenTree/plugin/base/supplier/serializers.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Serializer definitions for the supplier plugin base."""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
import part.models as part_models
|
||||
from part.serializers import PartSerializer
|
||||
|
||||
|
||||
class SupplierListSerializer(serializers.Serializer):
|
||||
"""Serializer for a supplier plugin."""
|
||||
|
||||
plugin_slug = serializers.CharField()
|
||||
supplier_slug = serializers.CharField()
|
||||
supplier_name = serializers.CharField()
|
||||
|
||||
|
||||
class SearchResultSerializer(serializers.Serializer):
|
||||
"""Serializer for a search result."""
|
||||
|
||||
class Meta:
|
||||
"""Meta options for the SearchResultSerializer."""
|
||||
|
||||
fields = [
|
||||
'id',
|
||||
'sku',
|
||||
'name',
|
||||
'exact',
|
||||
'description',
|
||||
'price',
|
||||
'link',
|
||||
'image_url',
|
||||
'existing_part_id',
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
id = serializers.CharField()
|
||||
sku = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
exact = serializers.BooleanField()
|
||||
description = serializers.CharField()
|
||||
price = serializers.CharField()
|
||||
link = serializers.CharField()
|
||||
image_url = serializers.CharField()
|
||||
existing_part_id = serializers.SerializerMethodField()
|
||||
|
||||
def get_existing_part_id(self, value) -> Optional[int]:
|
||||
"""Return the ID of the existing part if available."""
|
||||
return getattr(value.existing_part, 'pk', None)
|
||||
|
||||
|
||||
class ImportParameterSerializer(serializers.Serializer):
|
||||
"""Serializer for a ImportParameter."""
|
||||
|
||||
class Meta:
|
||||
"""Meta options for the ImportParameterSerializer."""
|
||||
|
||||
fields = ['name', 'value', 'parameter_template', 'on_category']
|
||||
|
||||
name = serializers.CharField()
|
||||
value = serializers.CharField()
|
||||
parameter_template = serializers.SerializerMethodField()
|
||||
on_category = serializers.BooleanField()
|
||||
|
||||
def get_parameter_template(self, value) -> Optional[int]:
|
||||
"""Return the ID of the parameter template if available."""
|
||||
return getattr(value.parameter_template, 'pk', None)
|
||||
|
||||
|
||||
class ImportRequestSerializer(serializers.Serializer):
|
||||
"""Serializer for the import request."""
|
||||
|
||||
plugin = serializers.CharField(required=True)
|
||||
supplier = serializers.CharField(required=True)
|
||||
part_import_id = serializers.CharField(required=True)
|
||||
category_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=part_models.PartCategory.objects.all(),
|
||||
many=False,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
part_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=part_models.Part.objects.all(),
|
||||
many=False,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
|
||||
class ImportResultSerializer(serializers.Serializer):
|
||||
"""Serializer for the import result."""
|
||||
|
||||
class Meta:
|
||||
"""Meta options for the ImportResultSerializer."""
|
||||
|
||||
fields = [
|
||||
'part_id',
|
||||
'part_detail',
|
||||
'manufacturer_part_id',
|
||||
'supplier_part_id',
|
||||
'pricing',
|
||||
'parameters',
|
||||
]
|
||||
|
||||
part_id = serializers.IntegerField()
|
||||
part_detail = PartSerializer()
|
||||
manufacturer_part_id = serializers.IntegerField()
|
||||
supplier_part_id = serializers.IntegerField()
|
||||
pricing = serializers.SerializerMethodField()
|
||||
parameters = ImportParameterSerializer(many=True)
|
||||
|
||||
def get_pricing(self, value: Any) -> list[tuple[float, str]]:
|
||||
"""Return the pricing data as a dictionary."""
|
||||
return value['pricing']
|
||||
@@ -20,6 +20,8 @@ from plugin.base.integration.ValidationMixin import ValidationMixin
|
||||
from plugin.base.label.mixins import LabelPrintingMixin
|
||||
from plugin.base.locate.mixins import LocateMixin
|
||||
from plugin.base.mail.mixins import MailMixin
|
||||
from plugin.base.supplier import helpers as supplier
|
||||
from plugin.base.supplier.mixins import SupplierMixin
|
||||
from plugin.base.ui.mixins import UserInterfaceMixin
|
||||
|
||||
__all__ = [
|
||||
@@ -41,8 +43,10 @@ __all__ = [
|
||||
'ScheduleMixin',
|
||||
'SettingsMixin',
|
||||
'SupplierBarcodeMixin',
|
||||
'SupplierMixin',
|
||||
'TransitionMixin',
|
||||
'UrlsMixin',
|
||||
'UserInterfaceMixin',
|
||||
'ValidationMixin',
|
||||
'supplier',
|
||||
]
|
||||
|
||||
@@ -75,6 +75,7 @@ class PluginMixinEnum(StringEnum):
|
||||
SCHEDULE = 'schedule'
|
||||
SETTINGS = 'settings'
|
||||
SETTINGS_CONTENT = 'settingscontent'
|
||||
SUPPLIER = 'supplier'
|
||||
STATE_TRANSITION = 'statetransition'
|
||||
SUPPLIER_BARCODE = 'supplier-barcode'
|
||||
URLS = 'urls'
|
||||
|
||||
182
src/backend/InvenTree/plugin/samples/supplier/supplier_sample.py
Normal file
182
src/backend/InvenTree/plugin/samples/supplier/supplier_sample.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""Sample supplier plugin."""
|
||||
|
||||
from company.models import Company, ManufacturerPart, SupplierPart, SupplierPriceBreak
|
||||
from part.models import Part
|
||||
from plugin.mixins import SupplierMixin, supplier
|
||||
from plugin.plugin import InvenTreePlugin
|
||||
|
||||
|
||||
class SampleSupplierPlugin(SupplierMixin, InvenTreePlugin):
|
||||
"""Example plugin to integrate with a dummy supplier."""
|
||||
|
||||
NAME = 'SampleSupplierPlugin'
|
||||
SLUG = 'samplesupplier'
|
||||
TITLE = 'My sample supplier plugin'
|
||||
|
||||
VERSION = '0.0.1'
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the sample supplier plugin."""
|
||||
super().__init__()
|
||||
|
||||
self.sample_data = []
|
||||
for material in ['Steel', 'Aluminium', 'Brass']:
|
||||
for size in ['M1', 'M2', 'M3', 'M4', 'M5']:
|
||||
for length in range(5, 30, 5):
|
||||
self.sample_data.append({
|
||||
'material': material,
|
||||
'thread': size,
|
||||
'length': length,
|
||||
'sku': f'BOLT-{material}-{size}-{length}',
|
||||
'name': f'Bolt {size}x{length}mm {material}',
|
||||
'description': f'This is a sample part description demonstration purposes for the {size}x{length} {material} bolt.',
|
||||
'price': {
|
||||
1: [1.0, 'EUR'],
|
||||
10: [0.9, 'EUR'],
|
||||
100: [0.8, 'EUR'],
|
||||
5000: [0.5, 'EUR'],
|
||||
},
|
||||
'link': f'https://example.com/sample-part-{size}-{length}-{material}',
|
||||
'image_url': r'https://github.com/inventree/demo-dataset/blob/main/media/part_images/flat-head.png?raw=true',
|
||||
'brand': 'Bolt Manufacturer',
|
||||
})
|
||||
|
||||
def get_suppliers(self) -> list[supplier.Supplier]:
|
||||
"""Return a list of available suppliers."""
|
||||
return [supplier.Supplier(slug='sample-fasteners', name='Sample Fasteners')]
|
||||
|
||||
def get_search_results(
|
||||
self, supplier_slug: str, term: str
|
||||
) -> list[supplier.SearchResult]:
|
||||
"""Return a list of search results based on the search term."""
|
||||
return [
|
||||
supplier.SearchResult(
|
||||
sku=p['sku'],
|
||||
name=p['name'],
|
||||
description=p['description'],
|
||||
exact=p['sku'] == term,
|
||||
price=f'{p["price"][1][0]:.2f}€',
|
||||
link=p['link'],
|
||||
image_url=p['image_url'],
|
||||
existing_part=getattr(
|
||||
SupplierPart.objects.filter(SKU=p['sku']).first(), 'part', None
|
||||
),
|
||||
)
|
||||
for p in self.sample_data
|
||||
if all(t.lower() in p['name'].lower() for t in term.split())
|
||||
]
|
||||
|
||||
def get_import_data(self, supplier_slug: str, part_id: str):
|
||||
"""Return import data for a specific part ID."""
|
||||
for p in self.sample_data:
|
||||
if p['sku'] == part_id:
|
||||
p = p.copy()
|
||||
p['variants'] = [
|
||||
x['sku']
|
||||
for x in self.sample_data
|
||||
if x['thread'] == p['thread'] and x['length'] == p['length']
|
||||
]
|
||||
return p
|
||||
|
||||
raise supplier.PartNotFoundError()
|
||||
|
||||
def get_pricing_data(self, data) -> dict[int, tuple[float, str]]:
|
||||
"""Return pricing data for the given part data."""
|
||||
return data['price']
|
||||
|
||||
def get_parameters(self, data) -> list[supplier.ImportParameter]:
|
||||
"""Return a list of parameters for the given part data."""
|
||||
return [
|
||||
supplier.ImportParameter(name='Thread', value=data['thread'][1:]),
|
||||
supplier.ImportParameter(name='Length', value=f'{data["length"]}mm'),
|
||||
supplier.ImportParameter(name='Material', value=data['material']),
|
||||
supplier.ImportParameter(name='Head', value='Flat Head'),
|
||||
]
|
||||
|
||||
def import_part(self, data, **kwargs) -> Part:
|
||||
"""Import a part based on the provided data."""
|
||||
part, created = Part.objects.get_or_create(
|
||||
name__iexact=data['sku'],
|
||||
purchaseable=True,
|
||||
defaults={
|
||||
'name': data['sku'],
|
||||
'description': data['description'],
|
||||
'link': data['link'],
|
||||
**kwargs,
|
||||
},
|
||||
)
|
||||
|
||||
# If the part was created, set additional fields
|
||||
if created:
|
||||
if data['image_url']:
|
||||
file, fmt = self.download_image(data['image_url'])
|
||||
filename = f'part_{part.pk}_image.{fmt.lower()}'
|
||||
part.image.save(filename, file)
|
||||
|
||||
# link other variants if they exist in our inventree database
|
||||
if len(data['variants']):
|
||||
# search for other parts that may already have a template part associated
|
||||
variant_parts = [
|
||||
x.part
|
||||
for x in SupplierPart.objects.filter(SKU__in=data['variants'])
|
||||
]
|
||||
parent_part = self.get_template_part(
|
||||
variant_parts,
|
||||
{
|
||||
# we cannot extract a real name for the root part, but we can try to guess a unique name
|
||||
'name': data['sku'].replace(data['material'] + '-', ''),
|
||||
'description': data['name'].replace(' ' + data['material'], ''),
|
||||
'link': data['link'],
|
||||
'image': part.image.name,
|
||||
'is_template': True,
|
||||
**kwargs,
|
||||
},
|
||||
)
|
||||
|
||||
# after the template part was created, we need to refresh the part from the db because its tree id may have changed
|
||||
# which results in an error if saved directly
|
||||
part.refresh_from_db()
|
||||
part.variant_of = parent_part # type: ignore
|
||||
part.save()
|
||||
|
||||
return part
|
||||
|
||||
def import_manufacturer_part(self, data, **kwargs) -> ManufacturerPart:
|
||||
"""Import a manufacturer part based on the provided data."""
|
||||
mft, _ = Company.objects.get_or_create(
|
||||
name__iexact=data['brand'],
|
||||
defaults={
|
||||
'is_manufacturer': True,
|
||||
'is_supplier': False,
|
||||
'name': data['brand'],
|
||||
},
|
||||
)
|
||||
|
||||
mft_part, created = ManufacturerPart.objects.get_or_create(
|
||||
MPN=f'MAN-{data["sku"]}', manufacturer=mft, **kwargs
|
||||
)
|
||||
|
||||
if created:
|
||||
# Attachments, notes, parameters and more can be added here
|
||||
pass
|
||||
|
||||
return mft_part
|
||||
|
||||
def import_supplier_part(self, data, **kwargs) -> SupplierPart:
|
||||
"""Import a supplier part based on the provided data."""
|
||||
spp, _ = SupplierPart.objects.get_or_create(
|
||||
SKU=data['sku'],
|
||||
supplier=self.supplier_company,
|
||||
**kwargs,
|
||||
defaults={'link': data['link']},
|
||||
)
|
||||
|
||||
SupplierPriceBreak.objects.filter(part=spp).delete()
|
||||
SupplierPriceBreak.objects.bulk_create([
|
||||
SupplierPriceBreak(
|
||||
part=spp, quantity=quantity, price=price, price_currency=currency
|
||||
)
|
||||
for quantity, (price, currency) in data['price'].items()
|
||||
])
|
||||
|
||||
return spp
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Unit tests for locate_sample sample plugins."""
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from company.models import ManufacturerPart, SupplierPart
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from part.models import (
|
||||
Part,
|
||||
PartCategory,
|
||||
PartCategoryParameterTemplate,
|
||||
PartParameterTemplate,
|
||||
)
|
||||
from plugin import registry
|
||||
|
||||
|
||||
class SampleSupplierTest(InvenTreeAPITestCase):
|
||||
"""Tests for SampleSupplierPlugin."""
|
||||
|
||||
fixtures = ['location', 'category', 'part', 'stock', 'company']
|
||||
roles = ['part.add']
|
||||
|
||||
def test_list(self):
|
||||
"""Check the list api."""
|
||||
# Test APIs
|
||||
url = reverse('api-supplier-list')
|
||||
|
||||
# No plugin
|
||||
res = self.get(url, expected_code=200)
|
||||
self.assertEqual(len(res.data), 0)
|
||||
|
||||
# Activate plugin
|
||||
config = registry.get_plugin('samplesupplier', active=None).plugin_config()
|
||||
config.active = True
|
||||
config.save()
|
||||
|
||||
# One active plugin
|
||||
res = self.get(url, expected_code=200)
|
||||
self.assertEqual(len(res.data), 1)
|
||||
self.assertEqual(res.data[0]['plugin_slug'], 'samplesupplier')
|
||||
self.assertEqual(res.data[0]['supplier_slug'], 'sample-fasteners')
|
||||
self.assertEqual(res.data[0]['supplier_name'], 'Sample Fasteners')
|
||||
|
||||
def test_search(self):
|
||||
"""Check the search api."""
|
||||
# Activate plugin
|
||||
config = registry.get_plugin('samplesupplier', active=None).plugin_config()
|
||||
config.active = True
|
||||
config.save()
|
||||
|
||||
# Test APIs
|
||||
url = reverse('api-supplier-search')
|
||||
|
||||
# No plugin
|
||||
self.get(
|
||||
url,
|
||||
{'plugin': 'non-existent-plugin', 'supplier': 'sample-fasteners'},
|
||||
expected_code=404,
|
||||
)
|
||||
|
||||
# No supplier
|
||||
self.get(
|
||||
url,
|
||||
{'plugin': 'samplesupplier', 'supplier': 'non-existent-supplier'},
|
||||
expected_code=404,
|
||||
)
|
||||
|
||||
# valid supplier
|
||||
res = self.get(
|
||||
url,
|
||||
{'plugin': 'samplesupplier', 'supplier': 'sample-fasteners', 'term': 'M5'},
|
||||
expected_code=200,
|
||||
)
|
||||
self.assertEqual(len(res.data), 15)
|
||||
self.assertEqual(res.data[0]['sku'], 'BOLT-Steel-M5-5')
|
||||
|
||||
def test_import_part(self):
|
||||
"""Test importing a part by supplier."""
|
||||
# Activate plugin
|
||||
plugin = registry.get_plugin('samplesupplier', active=None)
|
||||
config = plugin.plugin_config()
|
||||
config.active = True
|
||||
config.save()
|
||||
|
||||
# Test APIs
|
||||
url = reverse('api-supplier-import')
|
||||
|
||||
# No plugin
|
||||
self.post(
|
||||
url,
|
||||
{
|
||||
'plugin': 'non-existent-plugin',
|
||||
'supplier': 'sample-fasteners',
|
||||
'part_import_id': 'BOLT-Steel-M5-5',
|
||||
},
|
||||
expected_code=404,
|
||||
)
|
||||
|
||||
# No supplier
|
||||
self.post(
|
||||
url,
|
||||
{
|
||||
'plugin': 'samplesupplier',
|
||||
'supplier': 'non-existent-supplier',
|
||||
'part_import_id': 'BOLT-Steel-M5-5',
|
||||
},
|
||||
expected_code=404,
|
||||
)
|
||||
|
||||
# valid supplier, no part or category provided
|
||||
self.post(
|
||||
url,
|
||||
{
|
||||
'plugin': 'samplesupplier',
|
||||
'supplier': 'sample-fasteners',
|
||||
'part_import_id': 'BOLT-Steel-M5-5',
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
# valid supplier, but no supplier company set
|
||||
self.post(
|
||||
url,
|
||||
{
|
||||
'plugin': 'samplesupplier',
|
||||
'supplier': 'sample-fasteners',
|
||||
'part_import_id': 'BOLT-Steel-M5-5',
|
||||
'category_id': 1,
|
||||
},
|
||||
expected_code=500,
|
||||
)
|
||||
|
||||
# Set the supplier company now
|
||||
plugin.set_setting('SUPPLIER', 1)
|
||||
|
||||
# valid supplier, valid part import
|
||||
category = PartCategory.objects.get(pk=1)
|
||||
p_len = PartParameterTemplate(name='Length', units='mm')
|
||||
p_test = PartParameterTemplate(name='Test Parameter')
|
||||
p_len.save()
|
||||
p_test.save()
|
||||
PartCategoryParameterTemplate.objects.bulk_create([
|
||||
PartCategoryParameterTemplate(category=category, parameter_template=p_len),
|
||||
PartCategoryParameterTemplate(
|
||||
category=category, parameter_template=p_test, default_value='Test Value'
|
||||
),
|
||||
])
|
||||
res = self.post(
|
||||
url,
|
||||
{
|
||||
'plugin': 'samplesupplier',
|
||||
'supplier': 'sample-fasteners',
|
||||
'part_import_id': 'BOLT-Steel-M5-5',
|
||||
'category_id': 1,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
part = Part.objects.get(name='BOLT-Steel-M5-5')
|
||||
self.assertIsNotNone(part)
|
||||
self.assertEqual(part.pk, res.data['part_id'])
|
||||
|
||||
self.assertIsNotNone(SupplierPart.objects.get(pk=res.data['supplier_part_id']))
|
||||
self.assertIsNotNone(
|
||||
ManufacturerPart.objects.get(pk=res.data['manufacturer_part_id'])
|
||||
)
|
||||
|
||||
self.assertSetEqual(
|
||||
{x['name'] for x in res.data['parameters']},
|
||||
{'Thread', 'Length', 'Material', 'Head', 'Test Parameter'},
|
||||
)
|
||||
for p in res.data['parameters']:
|
||||
if p['name'] == 'Length':
|
||||
self.assertEqual(p['value'], '5mm')
|
||||
self.assertEqual(p['parameter_template'], p_len.pk)
|
||||
self.assertTrue(p['on_category'])
|
||||
elif p['name'] == 'Test Parameter':
|
||||
self.assertEqual(p['value'], 'Test Value')
|
||||
self.assertEqual(p['parameter_template'], p_test.pk)
|
||||
self.assertTrue(p['on_category'])
|
||||
|
||||
# valid supplier, import only manufacturer and supplier part
|
||||
part2 = Part.objects.create(name='Test Part', purchaseable=True)
|
||||
res = self.post(
|
||||
url,
|
||||
{
|
||||
'plugin': 'samplesupplier',
|
||||
'supplier': 'sample-fasteners',
|
||||
'part_import_id': 'BOLT-Steel-M5-10',
|
||||
'part_id': part2.pk,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(part2.pk, res.data['part_id'])
|
||||
sp = SupplierPart.objects.get(pk=res.data['supplier_part_id'])
|
||||
mp = ManufacturerPart.objects.get(pk=res.data['manufacturer_part_id'])
|
||||
self.assertIsNotNone(sp)
|
||||
self.assertIsNotNone(mp)
|
||||
self.assertEqual(sp.part.pk, part2.pk)
|
||||
self.assertEqual(mp.part.pk, part2.pk)
|
||||
|
||||
# PartNotFoundError
|
||||
self.post(
|
||||
url,
|
||||
{
|
||||
'plugin': 'samplesupplier',
|
||||
'supplier': 'sample-fasteners',
|
||||
'part_import_id': 'non-existent-part',
|
||||
'category_id': 1,
|
||||
},
|
||||
expected_code=404,
|
||||
)
|
||||
Reference in New Issue
Block a user