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:
@@ -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 |
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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: []
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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/',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user