2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-13 12:00:51 +00:00

Merge branch 'master' of https://github.com/inventree/InvenTree into filtered-testing

This commit is contained in:
Matthias Mair
2025-10-18 13:47:25 +02:00
150 changed files with 46662 additions and 37032 deletions
+1
View File
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added `order_queryset` report helper function in [#10439](https://github.com/inventree/InvenTree/pull/10439) - 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 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 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) - 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+4
View File
@@ -186,6 +186,10 @@ To see all the available options:
invoke dev.test --help invoke dev.test --help
``` ```
```
{{ invoke_commands('dev.test --help') }}
```
#### Database Permission Issues #### Database Permission Issues
For local testing django creates a test database and removes it after testing. If you encounter permission issues while running unit test, ensure that your database user has permission to create new databases. For local testing django creates a test database and removes it after testing. If you encounter permission issues while running unit test, ensure that your database user has permission to create new databases.
+45 -8
View File
@@ -2,17 +2,20 @@
title: Creating a Part title: Creating a Part
--- ---
## Part Creation Form ## Part Creation
New parts can be created from the *Part Category* view, by pressing the *New Part* button: New parts can be created manually via the web interface, or imported from an external source.
To create or import a part, navigate to the *Parts* view in the user interface, and select the *Add Parts* dropdown menu above the parts table:
{{ image("part/new_parts_dropdown.png", "Add parts dropdown") }}
!!! info "Permissions" !!! info "Permissions"
If the user does not have "create" permission for the *Part* permission group, the *New Part* button will not be available. If the user does not have "create" permission for the *Part* permission group, the *Add Parts* menu will not be available.
{{ image("part/new_part.png", "New part") }} ## Create Part Form
New parts can be created manually by selecting the *Create Part* option from the menu. A part creation form is opened as shown below:
A part creation form is opened as shown below:
{{ image("part/part_create_form.png", "New part form") }} {{ image("part/part_create_form.png", "New part form") }}
@@ -31,7 +34,6 @@ If this setting is enabled, the following elements are available in the form:
Checking the *Create Initial Stock* form input then allows the creation of an initial quantity of stock for the new part. Checking the *Create Initial Stock* form input then allows the creation of an initial quantity of stock for the new part.
### Supplier Options ### Supplier Options
If the part is marked as *Purchaseable*, the form provides some extra options to initialize the new part with manufacturer and / or supplier information: If the part is marked as *Purchaseable*, the form provides some extra options to initialize the new part with manufacturer and / or supplier information:
@@ -42,9 +44,44 @@ If the *Add Supplier Data* option is checked, then supplier part and manufacture
{{ image("part/part_new_suppliers.png", "Part supplier information") }} {{ image("part/part_new_suppliers.png", "Part supplier information") }}
## Import from File
Parts can be imported from an external file, by selecting the *Import from File* option.
This action opens the [data import wizard](../settings/import.md), which steps the user through the process of importing parts from the selected file.
## Import from Supplier
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.
!!! info "Plugin Required"
To import parts from a supplier, you must install a plugin which supports that supplier.
### 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") }}
## Other Part Creation Methods ## Other Part Creation Methods
The following alternative methods for creating parts are supported: In addition to the primary methods for creating or importing part data, the following methods are supported:
- [Via the REST API](../api/index.md) - [Via the REST API](../api/index.md)
- [Using the Python library](../api/python/index.md) - [Using the Python library](../api/python/index.md)
+48
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: []
+12 -1
View File
@@ -263,7 +263,11 @@ Total Price: {% render_currency order.total_price currency='NZD' decimal_places=
## Maths Operations ## Maths Operations
Simple mathematical operators are available, as demonstrated in the example template below: Simple mathematical operators are available, as demonstrated in the example template below. These operators can be used to perform basic arithmetic operations within the report template.
### Input Types
These mathematical functions accept inputs of various input types, and attempt to perform the operation accordingly. Note that any inputs which are provided as strings will be converted to floating point numbers before the operation is performed.
### add ### add
@@ -293,6 +297,13 @@ Simple mathematical operators are available, as demonstrated in the example temp
show_docstring_description: false show_docstring_description: false
show_source: False show_source: False
### modulo
::: report.templatetags.report.modulo
options:
show_docstring_description: false
show_source: False
### Example ### Example
```html ```html
+94 -8
View File
@@ -4,7 +4,7 @@ title: Data Backup
## Data Backup ## Data Backup
Backup functionality is provided natively using the [django-dbbackup library](https://django-dbbackup.readthedocs.io/en/master/). This library provides multiple options for creating backups of your InvenTree database and media files. In addition to local storage backup, multiple external storage solutions are supported (such as Amazon S3 or Dropbox). Backup functionality is provided natively using the [django-dbbackup library](https://archmonger.github.io/django-dbbackup/5.0.0/). This library provides multiple options for creating backups of your InvenTree database and media files. In addition to local storage backup, multiple external storage solutions are supported (such as Amazon S3 or Dropbox).
Note that a *backup* operation is not the same as [migrating data](./migrate.md). While data *migration* exports data into a database-agnostic JSON file, *backup* exports a native database file and media file archive. Note that a *backup* operation is not the same as [migrating data](./migrate.md). While data *migration* exports data into a database-agnostic JSON file, *backup* exports a native database file and media file archive.
@@ -13,19 +13,38 @@ Note that a *backup* operation is not the same as [migrating data](./migrate.md)
## Configuration ## Configuration
The django-dbbackup library provides [multiple configuration options](https://archmonger.github.io/django-dbbackup/5.0.0/configuration/), a subset of which are exposed via InvenTree.
The following configuration options are available for backup: The following configuration options are available for backup:
| Environment Variable | Configuration File | Description | Default | | Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| INVENTREE_BACKUP_STORAGE | backup_storage | Backup storage backend | django.core.files.storage.FileSystemStorage | | INVENTREE_BACKUP_STORAGE | backup_storage | Backup storage backend. Refer to the [storage backend documentation](#storage-backend). | django.core.files.storage.FileSystemStorage |
| INVENTREE_BACKUP_DIR | backup_dir | Backup storage directory | *No default* | | INVENTREE_BACKUP_DIR | backup_dir | Backup storage directory. | *No default* |
| INVENTREE_BACKUP_OPTIONS | backup_options | Specific backup options (dict) | *No default* | | INVENTREE_BACKUP_OPTIONS | backup_options | Specific options for the selected storage backend (dict) | *No default* |
| INVENTREE_BACKUP_CONNECTOR_OPTIONS | backup_connector_options | Specific options for the database connector (dict). Refer to the [database connector options](#database-connector). | *No default* |
| INVENTREE_BACKUP_SEND_EMAIL | backup_send_email | If True, an email is sent to the site admin when an error occurs during a backup or restore procedure. | False |
| INVENTREE_BACKUP_EMAIL_PREFIX | backup_email_prefix | Prefix for the subject line of backup-related emails. | `[InvenTree Backup]` |
| INVENTREE_BACKUP_GPG_RECIPIENT | backup_gpg_recipient | Specify GPG recipient if using encryption for backups. | *No default* |
| INVENTREE_BACKUP_DATE_FORMAT | backup_date_format | Date format string used to format timestamps in backup filenames. | `%Y-%m-%d-%H%M%S` |
| INVENTREE_BACKUP_DATABASE_FILENAME_TEMPLATE | backup_database_filename_template | Template string used to generate database backup filenames. | `InvenTree-db-{datetime}.{extension}` |
| INVENTREE_BACKUP_MEDIA_FILENAME_TEMPLATE | backup_media_filename_template | Template string used to generate media backup filenames. | `InvenTree-media-{datetime}.{extension}` |
### Storage Providers ### Storage Backend
If you want to use an external storage provider, extra configuration is required. As a starting point, refer to the [django-dbbackup documentation](https://django-dbbackup.readthedocs.io/en/master/storage.html). There are multiple backends available for storing and retrieving backup files. The default option is to use the local filesystem. Integration of other storage backends is provided by the django-storages library (which needs to be installed separately).
Specific storage configuration options are specified using the `backup_options` dict (in the [configuration file](./config.md#backup-file-storage)). If you want to use an external storage provider, extra configuration is required. As a starting point, refer to the [django-dbbackup documentation](https://archmonger.github.io/django-dbbackup/5.0.0/storage/).
Each storage backend may have its own specific configuration options and package requirements. Specific storage configuration options are specified using the `backup_options` dict (in the [configuration file](./config.md#backup-file-storage)), and passed through to the storage backend.
### Database Connector
Different database connection options are available, depending on the database backend in use.
These options can be passed through via the `INVENTREE_BACKUP_CONNECTOR_OPTIONS` environment variable, or via the `backup_connector_options` value in the configuration file.
Refer to the [database connector documentation](https://archmonger.github.io/django-dbbackup/5.0.0/databases/) for more information on the available options.
## Perform Backup ## Perform Backup
@@ -43,6 +62,10 @@ This will perform backup operation with the default parameters. To see all avail
invoke backup --help invoke backup --help
``` ```
```
{{ invoke_commands('backup --help') }}
```
### Backup During Update ### Backup During Update
When performing an update of your InvenTree installation - via either [docker](./docker.md) or [bare metal](./install.md) - a backup operation is automatically performed. When performing an update of your InvenTree installation - via either [docker](./docker.md) or [bare metal](./install.md) - a backup operation is automatically performed.
@@ -71,9 +94,72 @@ To see all available options for restore, run:
invoke restore --help invoke restore --help
``` ```
```
{{ invoke_commands('restore --help') }}
```
## View Backups
To view a list of available backups, run the following command from the shell:
```
invoke listbackups
```
## Backup Filename Formatting
There are multiple configuration options available to control the formatting of backup filenames. These options are described in the [configuration section](#configuration) above.
For more information about the available formatting options, refer to the [django-dbbackup documentation](https://archmonger.github.io/django-dbbackup/latest/configuration/#dbbackup_filename_template).
## Advanced Usage ## Advanced Usage
Not all functionality of the db-backup library is exposed by default. For advanced usage (not covered by the documentation above), refer to the [django-dbbackup commands documentation](https://django-dbbackup.readthedocs.io/en/master/commands.html). Not all functionality of the db-backup library is exposed by default. For advanced usage (not covered by the documentation above), refer to the [django-dbbackup commands documentation](https://archmonger.github.io/django-dbbackup/5.0.0/commands/).
!!! warning "Advanced Users Only" !!! warning "Advanced Users Only"
Any advanced usage assumes some underlying knowledge of django, and is not documented here. Any advanced usage assumes some underlying knowledge of django, and is not documented here.
## Example: Google Cloud Storage
By default, InvenTree backups are stored on the local filesystem. However, it is possible to configure remote storage backends, such as Google Cloud Storage (GCS). Below is a *brief* example of how you might configure GCS for backup storage. However, please note that this is for informational purposes only - the InvenTree project does not provide direct support for third-party storage backends.
### External Documentation
As a starting point, refer to the external documentation for django-dbbackup: https://archmonger.github.io/django-dbbackup/latest/storage/#google-cloud-storage
### Install Dependencies
You will need to install an additional package to enable GCS support:
```bash
pip install django-storages[google]
```
!!! tip "Python Environment"
Ensure you install the package into the same Python environment that InvenTree is installed in (e.g. virtual environment).
### Select Storage Backend
You will need to change the storage backend, which is set via the `INVENTREE_BACKUP_STORAGE` environment variable, or via `backup_storage` in the configuration file:
```yaml
backup_stoage: storages.backends.gcloud.GoogleCloudStorage
```
### Configure Backend Options
You will need to also specify the required options for the GCS backend. This is done via the `INVENTREE_BACKUP_OPTIONS` environment variable, or via `backup_options` in the configuration file. An example configuration might look like:
```yaml
backup_options:
bucket_name: 'your_bucket_name'
project_id: 'your_project_id'
```
### Advanced Configuration
There are other options available for the GCS storage backend - refer to the [GCS documentation](https://django-storages.readthedocs.io/en/latest/backends/gcloud.html) for more information.
### Other Backends
Other storage backends are also supported via the django-storages library, such as Amazon S3, Dropbox, and more. This is outside the scope of this documentation - refer to the external documentation links on this page for more information.
+4
View File
@@ -95,6 +95,10 @@ For example, to find more information about the `update` task, run:
invoke update --help invoke update --help
``` ```
```
{{ invoke_commands('update --help') }}
```
### Internal Tasks ### Internal Tasks
Tasks with the `int.` prefix are internal tasks, and are not intended for general use. These are called by other tasks, and should generally not be called directly. Tasks with the `int.` prefix are internal tasks, and are not intended for general use. These are called by other tasks, and should generally not be called directly.
+4
View File
@@ -31,6 +31,10 @@ This will create JSON file at the specified location which contains all database
!!! info "Specifying filename" !!! info "Specifying filename"
The filename of the exported file can be specified using the `-f` option. To see all available options, run `invoke export-records --help` The filename of the exported file can be specified using the `-f` option. To see all available options, run `invoke export-records --help`
```
{{ invoke_commands('export-records --help') }}
```
### Initialize New Database ### Initialize New Database
Configure the new database using the normal processes (see [Configuration](./config.md)) Configure the new database using the normal processes (see [Configuration](./config.md))
+2 -2
View File
@@ -233,12 +233,12 @@ def define_env(env):
return url return url
@env.macro @env.macro
def invoke_commands(): def invoke_commands(command: str = '--list'):
"""Provides an output of the available commands.""" """Provides an output of the available commands."""
tasks = here.parent.joinpath('tasks.py') tasks = here.parent.joinpath('tasks.py')
output = gen_base.joinpath('invoke-commands.txt') output = gen_base.joinpath('invoke-commands.txt')
command = f'invoke -f {tasks} --list > {output}' command = f'invoke -f {tasks} {command} > {output}'
assert subprocess.call(command, shell=True) == 0 assert subprocess.call(command, shell=True) == 0
+2 -1
View File
@@ -125,7 +125,7 @@ nav:
- Project Security: security.md - Project Security: security.md
- Resources: project/resources.md - Resources: project/resources.md
- Privacy: privacy.md - Privacy: privacy.md
- Install: - Setup:
- Introduction: start/index.md - Introduction: start/index.md
- Processes: start/processes.md - Processes: start/processes.md
- Configuration: start/config.md - Configuration: start/config.md
@@ -237,6 +237,7 @@ nav:
- Report Mixin: plugins/mixins/report.md - Report Mixin: plugins/mixins/report.md
- Schedule Mixin: plugins/mixins/schedule.md - Schedule Mixin: plugins/mixins/schedule.md
- Settings Mixin: plugins/mixins/settings.md - Settings Mixin: plugins/mixins/settings.md
- Supplier Mixin: plugins/mixins/supplier.md
- Transition Mixin: plugins/mixins/transition.md - Transition Mixin: plugins/mixins/transition.md
- URL Mixin: plugins/mixins/urls.md - URL Mixin: plugins/mixins/urls.md
- User Interface Mixin: plugins/mixins/ui.md - User Interface Mixin: plugins/mixins/ui.md
+36 -5
View File
@@ -1,5 +1,6 @@
"""Main JSON interface views.""" """Main JSON interface views."""
import collections
import json import json
from pathlib import Path from pathlib import Path
@@ -488,16 +489,46 @@ class BulkCreateMixin:
if isinstance(data, list): if isinstance(data, list):
created_items = [] created_items = []
errors = []
has_errors = False
# If data is a list, we assume it is a bulk create request # If data is a list, we assume it is a bulk create request
if len(data) == 0: if len(data) == 0:
raise ValidationError({'non_field_errors': _('No data provided')}) raise ValidationError({'non_field_errors': _('No data provided')})
for item in data: # validate unique together fields
serializer = self.get_serializer(data=item) if unique_create_fields := getattr(self, 'unique_create_fields', None):
serializer.is_valid(raise_exception=True) existing = collections.defaultdict(list)
self.perform_create(serializer) for idx, item in enumerate(data):
created_items.append(serializer.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) return Response(created_items, status=201)
@@ -1,12 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v409 -> 2025-10-17 : https://github.com/inventree/InvenTree/pull/10601
- Adds ability to filter StockList API by manufacturer part ID - Adds ability to filter StockList API by manufacturer part ID
+133
View File
@@ -0,0 +1,133 @@
"""Configuration options for InvenTree backup / restore functionality.
We use the django-dbbackup library to handle backup and restore operations.
Ref: https://archmonger.github.io/django-dbbackup/latest/configuration/
"""
import InvenTree.config
def get_backup_connector_options() -> dict:
"""Options which are specific to the selected backup connector.
These options apply to the database connector, not to the backup storage.
Ref: https://archmonger.github.io/django-dbbackup/latest/databases/
"""
default_options = {'EXCLUDE': ['django_session']}
# Allow user to specify custom options here if necessary
connector_options = InvenTree.config.get_setting(
'INVENTREE_BACKUP_CONNECTOR_OPTIONS',
'backup_connector_options',
default_value=default_options,
typecast=dict,
)
return connector_options
def get_backup_storage_backend() -> str:
"""Return the backup storage backend string."""
backend = InvenTree.config.get_setting(
'INVENTREE_BACKUP_STORAGE',
'backup_storage',
'django.core.files.storage.FileSystemStorage',
)
# Validate that the selected backend is valid
# It must be able to be imported, and a class must be found
# It also must be a subclass of django.core.files.storage.Storage
try:
from django.core.files.storage import Storage
from django.utils.module_loading import import_string
backend_class = import_string(backend)
if not issubclass(backend_class, Storage):
raise TypeError(
f"Backup storage backend '{backend}' is not a valid Storage class"
)
except Exception as e:
raise ImportError(f"Could not load backup storage backend '{backend}': {e}")
return backend
def get_backup_storage_options() -> dict:
"""Return the backup storage options dictionary."""
# Default backend options which are used for FileSystemStorage
default_options = {'location': InvenTree.config.get_backup_dir()}
options = InvenTree.config.get_setting(
'INVENTREE_BACKUP_OPTIONS',
'backup_options',
default_value=default_options,
typecast=dict,
)
if not isinstance(options, dict):
raise ValueError('Backup storage options must be a dictionary')
return options
def backup_email_on_error() -> bool:
"""Return whether to send emails to admins on backup failure."""
return InvenTree.config.get_setting(
'INVENTREE_BACKUP_SEND_EMAIL',
'backup_send_email',
default_value=False,
typecast=bool,
)
def backup_email_prefix() -> str:
"""Return the email subject prefix for backup emails."""
return InvenTree.config.get_setting(
'INVENTREE_BACKUP_EMAIL_PREFIX',
'backup_email_prefix',
default_value='[InvenTree Backup]',
typecast=str,
)
def backup_gpg_recipient() -> str:
"""Return the GPG recipient for encrypted backups."""
return InvenTree.config.get_setting(
'INVENTREE_BACKUP_GPG_RECIPIENT',
'backup_gpg_recipient',
default_value='',
typecast=str,
)
def backup_date_format() -> str:
"""Return the date format string for database backups."""
return InvenTree.config.get_setting(
'INVENTREE_BACKUP_DATE_FORMAT',
'backup_date_format',
default_value='%Y-%m-%d-%H%M%S',
typecast=str,
)
def backup_filename_template() -> str:
"""Return the filename template for database backups."""
return InvenTree.config.get_setting(
'INVENTREE_BACKUP_DATABASE_FILENAME_TEMPLATE',
'backup_database_filename_template',
default_value='InvenTree-db-{datetime}.{extension}',
typecast=str,
)
def backup_media_filename_template() -> str:
"""Return the filename template for media backups."""
return InvenTree.config.get_setting(
'INVENTREE_BACKUP_MEDIA_FILENAME_TEMPLATE',
'backup_media_filename_template',
default_value='InvenTree-media-{datetime}.{extension}',
typecast=str,
)
+25 -14
View File
@@ -24,6 +24,7 @@ from django.http import Http404, HttpResponseGone
import structlog import structlog
from corsheaders.defaults import default_headers as default_cors_headers from corsheaders.defaults import default_headers as default_cors_headers
import InvenTree.backup
from InvenTree.cache import get_cache_config, is_global_cache_enabled from InvenTree.cache import get_cache_config, is_global_cache_enabled
from InvenTree.config import ( from InvenTree.config import (
get_boolean_setting, get_boolean_setting,
@@ -247,22 +248,32 @@ if DEBUG and 'collectstatic' not in sys.argv:
STATICFILES_DIRS.append(BASE_DIR.joinpath('plugin', 'samples', 'static')) STATICFILES_DIRS.append(BASE_DIR.joinpath('plugin', 'samples', 'static'))
# Database backup options # Database backup options
# Ref: https://django-dbbackup.readthedocs.io/en/master/configuration.html # Ref: https://archmonger.github.io/django-dbbackup/latest/configuration/
DBBACKUP_SEND_EMAIL = False
DBBACKUP_STORAGE = get_setting(
'INVENTREE_BACKUP_STORAGE',
'backup_storage',
'django.core.files.storage.FileSystemStorage',
)
# Default backup configuration # For core backup functionality, refer to the STORAGES["dbbackup"] entry (below)
DBBACKUP_STORAGE_OPTIONS = get_setting(
'INVENTREE_BACKUP_OPTIONS',
'backup_options',
default_value={'location': config.get_backup_dir()},
typecast=dict,
)
DBBACKUP_DATE_FORMAT = InvenTree.backup.backup_date_format()
DBBACKUP_FILENAME_TEMPLATE = InvenTree.backup.backup_filename_template()
DBBACKUP_MEDIA_FILENAME_TEMPLATE = InvenTree.backup.backup_media_filename_template()
DBBACKUP_GPG_RECIPIENT = InvenTree.backup.backup_gpg_recipient()
DBBACKUP_SEND_EMAIL = InvenTree.backup.backup_email_on_error()
DBBACKUP_EMAIL_SUBJECT_PREFIX = InvenTree.backup.backup_email_prefix()
DBBACKUP_CONNECTORS = {'default': InvenTree.backup.get_backup_connector_options()}
# Data storage options
STORAGES = {
'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'},
'staticfiles': {'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage'},
'dbbackup': {
'BACKEND': InvenTree.backup.get_backup_storage_backend(),
'OPTIONS': InvenTree.backup.get_backup_storage_options(),
},
}
# Enable django admin interface?
INVENTREE_ADMIN_ENABLED = get_boolean_setting( INVENTREE_ADMIN_ENABLED = get_boolean_setting(
'INVENTREE_ADMIN_ENABLED', config_key='admin_enabled', default_value=True 'INVENTREE_ADMIN_ENABLED', config_key='admin_enabled', default_value=True
) )
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -17,6 +17,7 @@ from rest_framework.response import Response
import part.filters import part.filters
from data_exporter.mixins import DataExportViewMixin from data_exporter.mixins import DataExportViewMixin
from InvenTree.api import ( from InvenTree.api import (
BulkCreateMixin,
BulkDeleteMixin, BulkDeleteMixin,
BulkUpdateMixin, BulkUpdateMixin,
ListCreateDestroyAPIView, ListCreateDestroyAPIView,
@@ -1416,7 +1417,11 @@ class PartParameterFilter(FilterSet):
class PartParameterList( class PartParameterList(
PartParameterAPIMixin, OutputOptionsMixin, DataExportViewMixin, ListCreateAPI BulkCreateMixin,
PartParameterAPIMixin,
OutputOptionsMixin,
DataExportViewMixin,
ListCreateAPI,
): ):
"""API endpoint for accessing a list of PartParameter objects. """API endpoint for accessing a list of PartParameter objects.
@@ -1444,6 +1449,8 @@ class PartParameterList(
'template__units', 'template__units',
] ]
unique_create_fields = ['part', 'template']
class PartParameterDetail( class PartParameterDetail(
PartParameterAPIMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI PartParameterAPIMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
+5
View File
@@ -4271,6 +4271,11 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
for sub in self.substitutes.all(): for sub in self.substitutes.all():
parts.add(sub.part) parts.add(sub.part)
# Account for variants of the substitute part (if allowed)
if allow_variants and self.allow_variants:
for sub_variant in sub.part.get_descendants(include_self=False):
parts.add(sub_variant)
valid_parts = [] valid_parts = []
for p in parts: for p in parts:
+30
View File
@@ -364,6 +364,36 @@ class PartParameterTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), 8) 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): def test_param_detail(self):
"""Tests for the PartParameter detail endpoint.""" """Tests for the PartParameter detail endpoint."""
url = reverse('api-part-parameter-detail', kwargs={'pk': 5}) url = reverse('api-part-parameter-detail', kwargs={'pk': 5})
+2
View File
@@ -31,6 +31,7 @@ from InvenTree.mixins import (
from plugin.base.action.api import ActionPluginView from plugin.base.action.api import ActionPluginView
from plugin.base.barcodes.api import barcode_api_urls from plugin.base.barcodes.api import barcode_api_urls
from plugin.base.locate.api import LocatePluginView 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.base.ui.api import ui_plugins_api_urls
from plugin.models import PluginConfig, PluginSetting, PluginUserSetting from plugin.models import PluginConfig, PluginSetting, PluginUserSetting
from plugin.plugin import InvenTreePlugin from plugin.plugin import InvenTreePlugin
@@ -601,4 +602,5 @@ plugin_api_urls = [
path('', PluginList.as_view(), name='api-plugin-list'), path('', PluginList.as_view(), name='api-plugin-list'),
]), ]),
), ),
path('supplier/', include(supplier_api_urls)),
] ]
@@ -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'),
]
@@ -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."""
@@ -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
@@ -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.label.mixins import LabelPrintingMixin
from plugin.base.locate.mixins import LocateMixin from plugin.base.locate.mixins import LocateMixin
from plugin.base.mail.mixins import MailMixin 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 from plugin.base.ui.mixins import UserInterfaceMixin
__all__ = [ __all__ = [
@@ -41,8 +43,10 @@ __all__ = [
'ScheduleMixin', 'ScheduleMixin',
'SettingsMixin', 'SettingsMixin',
'SupplierBarcodeMixin', 'SupplierBarcodeMixin',
'SupplierMixin',
'TransitionMixin', 'TransitionMixin',
'UrlsMixin', 'UrlsMixin',
'UserInterfaceMixin', 'UserInterfaceMixin',
'ValidationMixin', 'ValidationMixin',
'supplier',
] ]
+1
View File
@@ -75,6 +75,7 @@ class PluginMixinEnum(StringEnum):
SCHEDULE = 'schedule' SCHEDULE = 'schedule'
SETTINGS = 'settings' SETTINGS = 'settings'
SETTINGS_CONTENT = 'settingscontent' SETTINGS_CONTENT = 'settingscontent'
SUPPLIER = 'supplier'
STATE_TRANSITION = 'statetransition' STATE_TRANSITION = 'statetransition'
SUPPLIER_BARCODE = 'supplier-barcode' SUPPLIER_BARCODE = 'supplier-barcode'
URLS = 'urls' URLS = 'urls'
@@ -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,
)
@@ -103,7 +103,7 @@ def filter_db_model(model_name: str, **kwargs) -> Optional[QuerySet]:
def getindex(container: list, index: int) -> Any: def getindex(container: list, index: int) -> Any:
"""Return the value contained at the specified index of the list. """Return the value contained at the specified index of the list.
This function is provideed to get around template rendering limitations. This function is provided to get around template rendering limitations.
Arguments: Arguments:
container: A python list object container: A python list object
@@ -413,28 +413,54 @@ def internal_link(link, text) -> str:
return mark_safe(f'<a href="{url}">{text}</a>') return mark_safe(f'<a href="{url}">{text}</a>')
@register.simple_tag() def destringify(value: Any) -> Any:
def add(x: float, y: float, *args, **kwargs) -> float: """Convert a string value into a float.
"""Add two numbers together."""
return float(x) + float(y) - If the value is a string, attempt to convert it to a float.
- If conversion fails, return the original string.
- If the value is not a string, return it unchanged.
The purpose of this function is to provide "seamless" math operations in templates,
where numeric values may be provided as strings, or converted to strings during template rendering.
"""
if isinstance(value, str):
value = value.strip()
try:
return float(value)
except ValueError:
return value
return value
@register.simple_tag() @register.simple_tag()
def subtract(x: float, y: float) -> float: def add(x: Any, y: Any) -> Any:
"""Subtract one number from another.""" """Add two numbers (or number like values) together."""
return float(x) - float(y) return destringify(x) + destringify(y)
@register.simple_tag() @register.simple_tag()
def multiply(x: float, y: float) -> float: def subtract(x: Any, y: Any) -> Any:
"""Multiply two numbers together.""" """Subtract one number (or number-like value) from another."""
return float(x) * float(y) return destringify(x) - destringify(y)
@register.simple_tag() @register.simple_tag()
def divide(x: float, y: float) -> float: def multiply(x: Any, y: Any) -> Any:
"""Divide one number by another.""" """Multiply two numbers (or number-like values) together."""
return float(x) / float(y) return destringify(x) * destringify(y)
@register.simple_tag()
def divide(x: Any, y: Any) -> Any:
"""Divide one number (or number-like value) by another."""
return destringify(x) / destringify(y)
@register.simple_tag()
def modulo(x: Any, y: Any) -> Any:
"""Calculate the modulo of one number (or number-like value) by another."""
return destringify(x) % destringify(y)
@register.simple_tag @register.simple_tag
+49
View File
@@ -203,9 +203,58 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
self.assertEqual(report_tags.multiply('-2', 4), -8.0) self.assertEqual(report_tags.multiply('-2', 4), -8.0)
self.assertEqual(report_tags.divide(100, 5), 20) self.assertEqual(report_tags.divide(100, 5), 20)
self.assertEqual(report_tags.modulo(10, 3), 1)
self.assertEqual(report_tags.modulo('10', '4'), 2)
with self.assertRaises(ZeroDivisionError): with self.assertRaises(ZeroDivisionError):
report_tags.divide(100, 0) report_tags.divide(100, 0)
def test_maths_tags_with_strings(self):
"""Tests for mathematical operator tags with string inputs."""
self.assertEqual(report_tags.add('10', '20'), 30)
self.assertEqual(report_tags.subtract('50.5', '20.2'), 30.3)
self.assertEqual(report_tags.multiply(3.0000000000000, '7'), 21)
self.assertEqual(report_tags.divide('100.0', '4'), 25.0)
def test_maths_tags_with_decimal(self):
"""Tests for mathematical operator tags with Decimal inputs."""
from decimal import Decimal
self.assertEqual(
report_tags.add(Decimal('1.1'), Decimal('2.2')), Decimal('3.3')
)
self.assertEqual(
report_tags.subtract(Decimal('5.5'), Decimal('2.2')), Decimal('3.3')
)
self.assertEqual(report_tags.multiply(Decimal('3.0'), 4), Decimal('12.0'))
self.assertEqual(
report_tags.divide(Decimal('10.0'), Decimal('2.000')), Decimal('5.0')
)
def test_maths_tags_with_money(self):
"""Tests for mathematical operator tags with Money inputs."""
m1 = Money(100, 'USD')
m2 = Money(50, 'USD')
self.assertEqual(report_tags.add(m1, m2), Money(150, 'USD'))
self.assertEqual(report_tags.subtract(m1, m2), Money(50, 'USD'))
self.assertEqual(report_tags.multiply(m2, 3), Money(150, 'USD'))
self.assertEqual(report_tags.divide(m1, '4'), Money(25, 'USD'))
def test_maths_tags_invalid(self):
"""Tests for mathematical operator tags with invalid inputs."""
with self.assertRaises(TypeError):
report_tags.add('abc', 10)
with self.assertRaises(TypeError):
report_tags.subtract(50, 'xyz')
with self.assertRaises(TypeError):
report_tags.multiply('foo', 'bar')
with self.assertRaises(TypeError):
report_tags.divide('100', 'baz')
def test_number_tags(self): def test_number_tags(self):
"""Simple tests for number formatting tags.""" """Simple tests for number formatting tags."""
fn = report_tags.format_number fn = report_tags.format_number
+1 -1
View File
@@ -6,7 +6,7 @@ django-anymail[amazon_ses,postal] # Email backend for various providers
django-allauth[mfa,socialaccount,saml,openid] # SSO for external providers via OpenID django-allauth[mfa,socialaccount,saml,openid] # SSO for external providers via OpenID
django-cleanup # Automated deletion of old / unused uploaded files django-cleanup # Automated deletion of old / unused uploaded files
django-cors-headers # CORS headers extension for DRF django-cors-headers # CORS headers extension for DRF
django-dbbackup # Backup / restore of database and media files django-dbbackup>=5.0.0 # Backup / restore of database and media files
django-error-report-2 # Error report viewer for the admin interface django-error-report-2 # Error report viewer for the admin interface
django-filter # Extended filtering options django-filter # Extended filtering options
django-flags # Feature flags django-flags # Feature flags
+3 -7
View File
@@ -438,9 +438,9 @@ django-cors-headers==4.9.0 \
--hash=sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449 \ --hash=sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449 \
--hash=sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8 --hash=sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8
# via -r src/backend/requirements.in # via -r src/backend/requirements.in
django-dbbackup==4.3.0 \ django-dbbackup==5.0.0 \
--hash=sha256:3549c8ccfdb167f20ca2c26eb0dfa55c79aa3f31ea4e4dbfa0816bc18ceec6dc \ --hash=sha256:a0301b14a4bb3c7243a2fde76d09f8f572f16cd7639f75f4cd42d898fc1b82a2 \
--hash=sha256:b4003b353d49d914ffbc033c793198426572d0a2b137ec795ddb2fb82225b960 --hash=sha256:aa9cc88e1413adfec0e547dd91e0afed6dbb91a02459697663a9b988dbc71f18
# via -r src/backend/requirements.in # via -r src/backend/requirements.in
django-error-report-2==0.4.2 \ django-error-report-2==0.4.2 \
--hash=sha256:1dd99c497af09b7ea99f5fbaf910501838150a9d5390796ea00e187bc62f6c1b \ --hash=sha256:1dd99c497af09b7ea99f5fbaf910501838150a9d5390796ea00e187bc62f6c1b \
@@ -1295,10 +1295,6 @@ python3-saml==1.16.0 \
--hash=sha256:97c9669aecabc283c6e5fb4eb264f446b6e006f5267d01c9734f9d8bffdac133 \ --hash=sha256:97c9669aecabc283c6e5fb4eb264f446b6e006f5267d01c9734f9d8bffdac133 \
--hash=sha256:c49097863c278ff669a337a96c46dc1f25d16307b4bb2679d2d1733cc4f5176a --hash=sha256:c49097863c278ff669a337a96c46dc1f25d16307b4bb2679d2d1733cc4f5176a
# via django-allauth # via django-allauth
pytz==2025.2 \
--hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \
--hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00
# via django-dbbackup
pyyaml==6.0.3 \ pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
+3
View File
@@ -220,6 +220,9 @@ export enum ApiEndpoints {
// Special plugin endpoints // Special plugin endpoints
plugin_locate_item = 'locate/', plugin_locate_item = 'locate/',
plugin_supplier_list = 'supplier/list/',
plugin_supplier_search = 'supplier/search/',
plugin_supplier_import = 'supplier/import/',
// Machine API endpoints // Machine API endpoints
machine_types_list = 'machine/types/', machine_types_list = 'machine/types/',
+6
View File
@@ -53,9 +53,12 @@ export type ApiFormFieldHeader = {
* @param error : Optional error message to display * @param error : Optional error message to display
* @param exclude : Whether to exclude the field from the submitted data * @param exclude : Whether to exclude the field from the submitted data
* @param placeholder : The placeholder text to display * @param placeholder : The placeholder text to display
* @param placeholderAutofill: Whether to allow auto-filling of the placeholder value
* @param description : The description to display for the field * @param description : The description to display for the field
* @param preFieldContent : Content to render before the field * @param preFieldContent : Content to render before the field
* @param postFieldContent : Content to render after the field * @param postFieldContent : Content to render after the field
* @param leftSection : Content to render in the left section of the field
* @param rightSection : Content to render in the right section of the field
* @param autoFill: Whether to automatically fill the field with data from the API * @param autoFill: Whether to automatically fill the field with data from the API
* @param autoFillFilters: Optional filters to apply when auto-filling the field * @param autoFillFilters: Optional filters to apply when auto-filling the field
* @param onValueChange : Callback function to call when the field value changes * @param onValueChange : Callback function to call when the field value changes
@@ -103,9 +106,12 @@ export type ApiFormFieldType = {
exclude?: boolean; exclude?: boolean;
read_only?: boolean; read_only?: boolean;
placeholder?: string; placeholder?: string;
placeholderAutofill?: boolean;
description?: string; description?: string;
preFieldContent?: JSX.Element; preFieldContent?: JSX.Element;
postFieldContent?: JSX.Element; postFieldContent?: JSX.Element;
leftSection?: JSX.Element;
rightSection?: JSX.Element;
autoFill?: boolean; autoFill?: boolean;
autoFillFilters?: any; autoFillFilters?: any;
adjustValue?: (value: any) => any; adjustValue?: (value: any) => any;
@@ -188,6 +188,7 @@ export function PrintingActions({
<ActionDropdown <ActionDropdown
tooltip={t`Printing Actions`} tooltip={t`Printing Actions`}
icon={<IconPrinter />} icon={<IconPrinter />}
position='bottom-start'
disabled={!enabled} disabled={!enabled}
actions={[ actions={[
{ {
@@ -115,6 +115,8 @@ export function OptionsApiForm({
if (!_props.fields) return _props; if (!_props.fields) return _props;
_props.fields = { ..._props.fields };
for (const [k, v] of Object.entries(_props.fields)) { for (const [k, v] of Object.entries(_props.fields)) {
_props.fields[k] = constructField({ _props.fields[k] = constructField({
field: v, field: v,
@@ -74,6 +74,7 @@ export function ApiFormField({
return { return {
...fieldDefinition, ...fieldDefinition,
autoFill: undefined, autoFill: undefined,
placeholderAutofill: undefined,
autoFillFilters: undefined, autoFillFilters: undefined,
onValueChange: undefined, onValueChange: undefined,
adjustFilters: undefined, adjustFilters: undefined,
@@ -146,6 +147,7 @@ export function ApiFormField({
return ( return (
<TextField <TextField
definition={reducedDefinition} definition={reducedDefinition}
placeholderAutofill={fieldDefinition.placeholderAutofill ?? false}
controller={controller} controller={controller}
fieldName={fieldName} fieldName={fieldName}
onChange={onChange} onChange={onChange}
@@ -1,4 +1,5 @@
import type { ApiFormFieldType } from '@lib/types/Forms'; import type { ApiFormFieldType } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import { DateInput } from '@mantine/dates'; import { DateInput } from '@mantine/dates';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
@@ -72,7 +73,7 @@ export default function DateField({
valueFormat={valueFormat} valueFormat={valueFormat}
label={definition.label} label={definition.label}
description={definition.description} description={definition.description}
placeholder={definition.placeholder} placeholder={definition.placeholder ?? t`Select date`}
leftSection={definition.icon} leftSection={definition.icon}
highlightToday highlightToday
/> />
@@ -1,7 +1,15 @@
import { TextInput } from '@mantine/core'; import { t } from '@lingui/core/macro';
import { TextInput, Tooltip } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks'; import { useDebouncedValue } from '@mantine/hooks';
import { IconX } from '@tabler/icons-react'; import { IconCopyCheck, IconX } from '@tabler/icons-react';
import { useCallback, useEffect, useId, useState } from 'react'; import {
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useState
} from 'react';
import type { FieldValues, UseControllerReturn } from 'react-hook-form'; import type { FieldValues, UseControllerReturn } from 'react-hook-form';
/* /*
@@ -13,12 +21,14 @@ export default function TextField({
controller, controller,
fieldName, fieldName,
definition, definition,
placeholderAutofill,
onChange, onChange,
onKeyDown onKeyDown
}: Readonly<{ }: Readonly<{
controller: UseControllerReturn<FieldValues, any>; controller: UseControllerReturn<FieldValues, any>;
definition: any; definition: any;
fieldName: string; fieldName: string;
placeholderAutofill?: boolean;
onChange: (value: any) => void; onChange: (value: any) => void;
onKeyDown: (value: any) => void; onKeyDown: (value: any) => void;
}>) { }>) {
@@ -28,7 +38,7 @@ export default function TextField({
fieldState: { error } fieldState: { error }
} = controller; } = controller;
const { value } = field; const { value } = useMemo(() => field, [field]);
const [rawText, setRawText] = useState<string>(value || ''); const [rawText, setRawText] = useState<string>(value || '');
@@ -48,6 +58,44 @@ export default function TextField({
} }
}, [debouncedText]); }, [debouncedText]);
// Construct a "right section" for the text field
const textFieldRightSection: ReactNode = useMemo(() => {
if (definition.rightSection) {
// Use the specified override value
return definition.rightSection;
} else if (value) {
if (!definition.required && !definition.disabled) {
// Render a button to clear the text field
return (
<Tooltip label={t`Clear`} position='top-end'>
<IconX
aria-label={`text-field-${fieldName}-clear`}
size='1rem'
color='red'
onClick={() => onTextChange('')}
/>
</Tooltip>
);
}
} else if (
!value &&
definition.placeholder &&
placeholderAutofill &&
!definition.disabled
) {
return (
<Tooltip label={t`Accept suggested value`} position='top-end'>
<IconCopyCheck
aria-label={`text-field-${fieldName}-accept-placeholder`}
size='1rem'
color='green'
onClick={() => onTextChange(definition.placeholder)}
/>
</Tooltip>
);
}
}, [placeholderAutofill, definition, value]);
return ( return (
<TextInput <TextInput
{...definition} {...definition}
@@ -71,11 +119,7 @@ export default function TextField({
} }
onKeyDown(event.code); onKeyDown(event.code);
}} }}
rightSection={ rightSection={textFieldRightSection}
value && !definition.required ? (
<IconX size='1rem' color='red' onClick={() => onTextChange('')} />
) : null
}
/> />
); );
} }
@@ -48,7 +48,8 @@ export function ActionDropdown({
actions, actions,
disabled = false, disabled = false,
hidden = false, hidden = false,
noindicator = false noindicator = false,
position
}: { }: {
icon: ReactNode; icon: ReactNode;
tooltip: string; tooltip: string;
@@ -57,6 +58,7 @@ export function ActionDropdown({
disabled?: boolean; disabled?: boolean;
hidden?: boolean; hidden?: boolean;
noindicator?: boolean; noindicator?: boolean;
position?: FloatingPosition;
}): ReactNode { }): ReactNode {
const hasActions = useMemo(() => { const hasActions = useMemo(() => {
return actions.some((action) => !action.hidden); return actions.some((action) => !action.hidden);
@@ -71,7 +73,7 @@ export function ActionDropdown({
}, [tooltip]); }, [tooltip]);
return !hidden && hasActions ? ( return !hidden && hasActions ? (
<Menu position='bottom-end' key={menuName}> <Menu position={position ?? 'bottom-end'} key={menuName}>
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}> <Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
<Menu.Target> <Menu.Target>
<Tooltip <Tooltip
@@ -0,0 +1,816 @@
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, IconSearch } 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
color='blue'
disabled={!searchValue || !supplier}
type='submit'
leftSection={<IconSearch />}
>
<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 Supplier Part`,
steps: [
t`Search Supplier Part`,
// if partId is provided, a inventree part already exists, just import the mp/sp
...(!partId ? [t`Category`, t`Parameters`] : [t`Confirm import`]),
t`Stock`,
t`Done`
],
onClose,
renderStep: renderStep,
disableManualStepChange: true
});
return wizard;
}
@@ -5,7 +5,6 @@ import {
Divider, Divider,
Drawer, Drawer,
Group, Group,
Paper,
Space, Space,
Stack, Stack,
Stepper, Stepper,
@@ -26,11 +25,13 @@ import { StylishText } from '../items/StylishText';
function WizardProgressStepper({ function WizardProgressStepper({
currentStep, currentStep,
steps, steps,
onSelectStep onSelectStep,
disableManualStepChange = false
}: { }: {
currentStep: number; currentStep: number;
steps: string[]; steps: string[];
onSelectStep: (step: number) => void; onSelectStep: (step: number) => void;
disableManualStepChange?: boolean;
}) { }) {
if (!steps || steps.length == 0) { if (!steps || steps.length == 0) {
return null; return null;
@@ -54,23 +55,32 @@ function WizardProgressStepper({
return ( return (
<Card p='xs' withBorder> <Card p='xs' withBorder>
<Group justify='space-between' gap='xs' wrap='nowrap'> <Group
<Tooltip justify={disableManualStepChange ? 'center' : 'space-between'}
label={steps[currentStep - 1]} gap='xs'
position='top' wrap='nowrap'
disabled={!canStepBackward} >
> {!disableManualStepChange && (
<ActionIcon <Tooltip
variant='transparent' label={steps[currentStep - 1]}
onClick={() => onSelectStep(currentStep - 1)} position='top'
disabled={!canStepBackward} disabled={!canStepBackward}
> >
<IconArrowLeft /> <ActionIcon
</ActionIcon> variant='transparent'
</Tooltip> onClick={() => onSelectStep(currentStep - 1)}
disabled={!canStepBackward}
>
<IconArrowLeft />
</ActionIcon>
</Tooltip>
)}
<Stepper <Stepper
active={currentStep} active={currentStep}
onStepClick={(stepIndex: number) => onSelectStep(stepIndex)} onStepClick={(stepIndex: number) => {
if (disableManualStepChange) return;
onSelectStep(stepIndex);
}}
iconSize={20} iconSize={20}
size='xs' size='xs'
> >
@@ -84,19 +94,21 @@ function WizardProgressStepper({
))} ))}
</Stepper> </Stepper>
{canStepForward ? ( {canStepForward ? (
<Tooltip !disableManualStepChange && (
label={steps[currentStep + 1]} <Tooltip
position='top' label={steps[currentStep + 1]}
disabled={!canStepForward} position='top'
>
<ActionIcon
variant='transparent'
onClick={() => onSelectStep(currentStep + 1)}
disabled={!canStepForward} disabled={!canStepForward}
> >
<IconArrowRight /> <ActionIcon
</ActionIcon> variant='transparent'
</Tooltip> onClick={() => onSelectStep(currentStep + 1)}
disabled={!canStepForward || disableManualStepChange}
>
<IconArrowRight />
</ActionIcon>
</Tooltip>
)
) : ( ) : (
<Tooltip label={t`Complete`} position='top'> <Tooltip label={t`Complete`} position='top'>
<ActionIcon color='green' variant='transparent'> <ActionIcon color='green' variant='transparent'>
@@ -120,7 +132,8 @@ export default function WizardDrawer({
opened, opened,
onClose, onClose,
onNextStep, onNextStep,
onPreviousStep onPreviousStep,
disableManualStepChange
}: { }: {
title: string; title: string;
currentStep: number; currentStep: number;
@@ -130,6 +143,7 @@ export default function WizardDrawer({
onClose: () => void; onClose: () => void;
onNextStep?: () => void; onNextStep?: () => void;
onPreviousStep?: () => void; onPreviousStep?: () => void;
disableManualStepChange?: boolean;
}) { }) {
const titleBlock: ReactNode = useMemo(() => { const titleBlock: ReactNode = useMemo(() => {
return ( return (
@@ -145,7 +159,9 @@ export default function WizardDrawer({
<WizardProgressStepper <WizardProgressStepper
currentStep={currentStep} currentStep={currentStep}
steps={steps} steps={steps}
disableManualStepChange={disableManualStepChange}
onSelectStep={(step: number) => { onSelectStep={(step: number) => {
if (disableManualStepChange) return;
if (step < currentStep) { if (step < currentStep) {
onPreviousStep?.(); onPreviousStep?.();
} else { } else {
@@ -179,10 +195,7 @@ export default function WizardDrawer({
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
> >
<Boundary label='wizard-drawer'> <Boundary label='wizard-drawer'>{children}</Boundary>
<Paper p='md'>{}</Paper>
{children}
</Boundary>
</Drawer> </Drawer>
); );
} }
+6 -9
View File
@@ -107,9 +107,8 @@ export function useBuildOrderFields({
icon: <IconTruckDelivery /> icon: <IconTruckDelivery />
}, },
batch: { batch: {
placeholder: placeholder: batchGenerator.result,
batchGenerator.result && placeholderAutofill: true,
`${t`Next batch code`}: ${batchGenerator.result}`,
value: batchCode, value: batchCode,
onValueChange: (value: any) => setBatchCode(value) onValueChange: (value: any) => setBatchCode(value)
}, },
@@ -207,14 +206,12 @@ export function useBuildOrderOutputFields({
}, },
serial_numbers: { serial_numbers: {
hidden: !trackable, hidden: !trackable,
placeholder: placeholder: serialGenerator.result && `${serialGenerator.result}+`,
serialGenerator.result && placeholderAutofill: true
`${t`Next serial number`}: ${serialGenerator.result}`
}, },
batch_code: { batch_code: {
placeholder: placeholder: batchGenerator.result,
batchGenerator.result && placeholderAutofill: true
`${t`Next batch code`}: ${batchGenerator.result}`
}, },
location: { location: {
value: location, value: location,
+64 -19
View File
@@ -67,22 +67,33 @@ import { StatusFilterOptions } from '../tables/Filter';
export function useStockFields({ export function useStockFields({
partId, partId,
stockItem, stockItem,
modalId, create = false,
create = false supplierPartId,
pricing,
modalId
}: { }: {
partId?: number; partId?: number;
stockItem?: any; stockItem?: any;
modalId: string; modalId: string;
create: boolean; create: boolean;
supplierPartId?: number;
pricing?: { [priceBreak: number]: [number, string] };
}): ApiFormFieldSet { }): ApiFormFieldSet {
const globalSettings = useGlobalSettingsState(); const globalSettings = useGlobalSettingsState();
// Keep track of the "part" instance // Keep track of the "part" instance
const [partInstance, setPartInstance] = useState<any>({}); 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 [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({ const batchGenerator = useBatchCodeGenerator({
modalId: modalId, 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(() => { return useMemo(() => {
const fields: ApiFormFieldSet = { const fields: ApiFormFieldSet = {
part: { part: {
value: partId || partInstance?.pk, value: partInstance.pk,
disabled: !create, disabled: !create || !!partId,
filters: { filters: {
virtual: false, virtual: false,
active: create ? true : undefined active: create ? true : undefined
@@ -135,6 +165,7 @@ export function useStockFields({
}, },
supplier_part: { supplier_part: {
hidden: partInstance?.purchaseable == false, hidden: partInstance?.purchaseable == false,
disabled: !!supplierPartId,
value: supplierPart, value: supplierPart,
onValueChange: (value) => { onValueChange: (value) => {
setSupplierPart(value); setSupplierPart(value);
@@ -171,6 +202,7 @@ export function useStockFields({
description: t`Enter initial quantity for this stock item`, description: t`Enter initial quantity for this stock item`,
onValueChange: (value) => { onValueChange: (value) => {
batchGenerator.update({ quantity: value }); batchGenerator.update({ quantity: value });
setQuantity(value);
} }
}, },
serial_numbers: { serial_numbers: {
@@ -180,23 +212,21 @@ export function useStockFields({
description: t`Enter serial numbers for new stock (or leave blank)`, description: t`Enter serial numbers for new stock (or leave blank)`,
required: false, required: false,
hidden: !create, hidden: !create,
placeholder: placeholderAutofill: true,
serialGenerator.result && placeholder: serialGenerator.result && `${serialGenerator.result}+`
`${t`Next serial number`}: ${serialGenerator.result}`
}, },
serial: { serial: {
placeholder: placeholderAutofill: true,
serialGenerator.result && placeholder: serialGenerator.result,
`${t`Next serial number`}: ${serialGenerator.result}`,
hidden: hidden:
create || create ||
partInstance.trackable == false || partInstance.trackable == false ||
(stockItem?.quantity != undefined && stockItem?.quantity != 1) (stockItem?.quantity != undefined && stockItem?.quantity != 1)
}, },
batch: { batch: {
placeholder: default: '',
batchGenerator.result && placeholderAutofill: true,
`${t`Next batch code`}: ${batchGenerator.result}` placeholder: batchGenerator.result
}, },
status_custom_key: { status_custom_key: {
label: t`Stock Status` label: t`Stock Status`
@@ -210,10 +240,18 @@ export function useStockFields({
} }
}, },
purchase_price: { purchase_price: {
icon: <IconCurrencyDollar /> icon: <IconCurrencyDollar />,
value: purchasePrice,
onValueChange: (value) => {
setPurchasePrice(value);
}
}, },
purchase_price_currency: { purchase_price_currency: {
icon: <IconCoins /> icon: <IconCoins />,
value: purchasePriceCurrency,
onValueChange: (value) => {
setPurchasePriceCurrency(value);
}
}, },
packaging: { packaging: {
icon: <IconPackage /> icon: <IconPackage />
@@ -232,6 +270,10 @@ export function useStockFields({
delete fields.expiry_date; delete fields.expiry_date;
} }
if (!create) {
delete fields.serial_numbers;
}
return fields; return fields;
}, [ }, [
stockItem, stockItem,
@@ -240,6 +282,10 @@ export function useStockFields({
partId, partId,
globalSettings, globalSettings,
supplierPart, supplierPart,
create,
supplierPartId,
purchasePrice,
purchasePriceCurrency,
serialGenerator.result, serialGenerator.result,
batchGenerator.result, batchGenerator.result,
create create
@@ -356,9 +402,8 @@ export function useStockItemSerializeFields({
return { return {
quantity: {}, quantity: {},
serial_numbers: { serial_numbers: {
placeholder: placeholder: serialGenerator.result && `${serialGenerator.result}+`,
serialGenerator.result && placeholderAutofill: true
`${t`Next serial number`}: ${serialGenerator.result}`
}, },
destination: {} destination: {}
}; };
+33 -16
View File
@@ -12,6 +12,8 @@ import WizardDrawer from '../components/wizards/WizardDrawer';
export interface WizardProps { export interface WizardProps {
title: string; title: string;
steps: string[]; steps: string[];
disableManualStepChange?: boolean;
onClose?: () => void;
renderStep: (step: number) => ReactNode; renderStep: (step: number) => ReactNode;
canStepForward?: (step: number) => boolean; canStepForward?: (step: number) => boolean;
canStepBackward?: (step: number) => boolean; canStepBackward?: (step: number) => boolean;
@@ -30,6 +32,7 @@ export interface WizardState {
nextStep: () => void; nextStep: () => void;
previousStep: () => void; previousStep: () => void;
wizard: ReactNode; wizard: ReactNode;
setStep: (step: number) => void;
} }
/** /**
@@ -65,32 +68,44 @@ export default function useWizard(props: WizardProps): WizardState {
// Close the wizard // Close the wizard
const closeWizard = useCallback(() => { const closeWizard = useCallback(() => {
props.onClose?.();
setOpened(false); setOpened(false);
}, []); }, []);
// Progress the wizard to the next step // Progress the wizard to the next step
const nextStep = useCallback(() => { const nextStep = useCallback(() => {
if (props.canStepForward && !props.canStepForward(currentStep)) { setCurrentStep((c) => {
return; if (props.canStepForward && !props.canStepForward(c)) {
} return c;
}
if (props.steps && currentStep < props.steps.length - 1) { const newStep = Math.min(c + 1, props.steps.length - 1);
setCurrentStep(currentStep + 1); if (newStep !== c) clearError();
clearError(); return newStep;
} });
}, [currentStep, props.canStepForward]); }, [props.canStepForward]);
// Go back to the previous step // Go back to the previous step
const previousStep = useCallback(() => { const previousStep = useCallback(() => {
if (props.canStepBackward && !props.canStepBackward(currentStep)) { setCurrentStep((c) => {
return; 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) { const setStep = useCallback(
setCurrentStep(currentStep - 1); (step: number) => {
if (step < 0 || step >= props.steps.length) {
return;
}
setCurrentStep(step);
clearError(); clearError();
} },
}, [currentStep, props.canStepBackward]); [props.steps.length]
);
// Render the wizard contents for the current step // Render the wizard contents for the current step
const contents = useMemo(() => { const contents = useMemo(() => {
@@ -109,8 +124,10 @@ export default function useWizard(props: WizardProps): WizardState {
closeWizard, closeWizard,
nextStep, nextStep,
previousStep, previousStep,
setStep,
wizard: ( wizard: (
<WizardDrawer <WizardDrawer
disableManualStepChange={props.disableManualStepChange}
title={props.title} title={props.title}
currentStep={currentStep} currentStep={currentStep}
steps={props.steps} steps={props.steps}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More