Skip to content

Supplier Mixin #9761

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/docs/plugins/mixins/supplier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
title: Supplier Mixin
---

## SupplierMixin

The `SupplierMixin` class enables plugins to integrate with external suppliers, enabling seamless creation of parts, supplier parts, and manufacturer parts with just a few clicks from the supplier. The import process is split into multiple phases:

- Search supplier
- Select InvenTree category
- Match Part Parameters
- Create initial Stock

### Import Methods

When a user initiates a search through the UI, the `get_search_results` function is called, and the search results are returned. These contain a `part_id` which is then passed to `get_import_data` if a user decides to import that specific part. This function should return a bunch of data that is needed for the import process. This data may be cached in the future for the same `part_id`. Then depending if the user only wants to import the supplier and manufacturer part or the whole part, the `import_part`, `import_manufacturer_part` and `import_supplier_part` methods are called automatically. If the user has imported the complete part, the `get_parameters` method is used to get a list of parameters which then can be match to inventree part parameter templates with some provided guidance. Additionally the `get_pricing_data` method is used to extract price breaks which are automatically considered when creating initial stock through the UI in the part import wizard.

For that to work, a few methods need to be overridden:

::: plugin.base.supplier.mixins.SupplierMixin
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
summary: False
members:
- get_search_results
- get_import_data
- get_pricing_data
- get_parameters
- import_part
- import_manufacturer_part
- import_supplier_part
extra:
show_sources: True

### Sample Plugin

A simple example is provided in the InvenTree code base. Note that this uses some static data, but this can be extended in a real world plugin to e.g. call the supplier's API:

::: plugin.samples.supplier.supplier_sample.SampleSupplierPlugin
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ nav:
- Report Mixin: plugins/mixins/report.md
- Schedule Mixin: plugins/mixins/schedule.md
- Settings Mixin: plugins/mixins/settings.md
- Supplier Mixin: plugins/mixins/supplier.md
- URL Mixin: plugins/mixins/urls.md
- User Interface Mixin: plugins/mixins/ui.md
- Validation Mixin: plugins/mixins/validation.md
Expand Down
6 changes: 5 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 348
INVENTREE_API_VERSION = 349

"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""


INVENTREE_API_TEXT = """
v349 -> 2025-06-12 : https://github.com/inventree/InvenTree/pull/9761
- Add supplier search and import API endpoints
- Add part parameter bulk create API endpoint

v348 -> 2025-04-22 : https://github.com/inventree/InvenTree/pull/9312
- Adds "external" flag for BuildOrder
- Adds link between PurchaseOrderLineItem and BuildOrder
Expand Down
49 changes: 49 additions & 0 deletions src/backend/InvenTree/InvenTree/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,3 +529,52 @@ def validate_remote_image(self, url):
raise ValidationError(_('Failed to download image from remote URL'))

return url


class ListUniqueValidator:
"""List validator that validates unique fields for bulk create.

See: https://github.com/encode/django-rest-framework/issues/6395#issuecomment-452412653
"""

message = 'This field must be unique.'

def __init__(self, unique_field_names):
"""Initialize the validator with a list of unique field names."""
self.unique_field_names = unique_field_names

@staticmethod
def has_duplicates(counter):
"""Check if there are any duplicate values in the counter."""
return any(count for count in counter.values() if count > 1)

def __call__(self, value):
"""Validate that the specified fields are unique across the list of items."""
from collections import Counter

field_counters = {
field_name: Counter(
item[field_name] for item in value if field_name in item
)
for field_name in self.unique_field_names
}
has_duplicates = any(
ListUniqueValidator.has_duplicates(counter)
for counter in field_counters.values()
)
if has_duplicates:
errors = []
for item in value:
error = {}
for field_name in self.unique_field_names:
counter = field_counters[field_name]
if counter[item.get(field_name)] > 1:
error[field_name] = self.message
errors.append(error)
raise ValidationError(errors)

def __repr__(self):
"""Return a string representation of the validator."""
return (
f'<{self.__class__.__name__}(unique_field_names={self.unique_field_names})>'
)
23 changes: 23 additions & 0 deletions src/backend/InvenTree/part/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.generics import CreateAPIView
from rest_framework.response import Response

import InvenTree.permissions
Expand Down Expand Up @@ -1700,6 +1701,23 @@ class PartParameterList(PartParameterAPIMixin, DataExportViewMixin, ListCreateAP
]


class PartParameterBulkCreate(CreateAPIView):
"""Bulk create part parameters.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wolflu05 it might be worth looking into a BulkCreateMixin class here, to make this a generic approach.

I recently added in a BulkUpdateMixin - #9313 - which employs a very similar approach. The intent here is to:

  1. Reduce API calls for better throughput and atomicity
  2. Utilize the already defined serializer classes for back-end validation

So, thoughts? A BulkCreateMixin would complement the BulkUpdateMixin and BulkDeleteMixin classess nicely!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I'll see what I can do and if I need any pointers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you submit that as a separate PR first? I'd like to be able to review that separately


- POST: Bulk create part parameters
"""

serializer_class = part_serializers.PartParameterBulkSerializer
queryset = PartParameter.objects.all()

def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this API endpoint."""
if isinstance(kwargs.get('data', {}), list):
kwargs['many'] = True

return super().get_serializer(*args, **kwargs)


class PartParameterDetail(PartParameterAPIMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single PartParameter object."""

Expand Down Expand Up @@ -2182,6 +2200,11 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI):
),
]),
),
path(
'bulk/',
PartParameterBulkCreate.as_view(),
name='api-part-parameter-bulk-create',
),
path('', PartParameterList.as_view(), name='api-part-parameter-list'),
]),
),
Expand Down
23 changes: 23 additions & 0 deletions src/backend/InvenTree/part/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,29 @@ def __init__(self, *args, **kwargs):
)


class TemplateUniquenessListSerializer(serializers.ListSerializer):
"""List serializer that validates unique fields for bulk create."""

validators = [
InvenTree.serializers.ListUniqueValidator(unique_field_names=['template'])
]


class PartParameterBulkSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""JSON serializers for the PartParameter model."""

class Meta:
"""Metaclass defining serializer fields."""

model = PartParameter
fields = ['pk', 'part', 'template', 'data', 'data_numeric']
list_serializer_class = TemplateUniquenessListSerializer

def __init__(self, *args, **kwargs):
"""Custom initialization method for the serializer."""
serializers.ModelSerializer.__init__(self, *args, **kwargs)


class DuplicatePartSerializer(serializers.Serializer):
"""Serializer for specifying options when duplicating a Part.

Expand Down
2 changes: 2 additions & 0 deletions src/backend/InvenTree/plugin/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from plugin.base.action.api import ActionPluginView
from plugin.base.barcodes.api import barcode_api_urls
from plugin.base.locate.api import LocatePluginView
from plugin.base.supplier.api import supplier_api_urls
from plugin.base.ui.api import ui_plugins_api_urls
from plugin.models import PluginConfig, PluginSetting
from plugin.plugin import InvenTreePlugin
Expand Down Expand Up @@ -525,4 +526,5 @@ class PluginMetadataView(MetadataView):
path('', PluginList.as_view(), name='api-plugin-list'),
]),
),
path('supplier/', include(supplier_api_urls)),
]
Empty file.
Loading
Loading