mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +00:00
[Feature] Data export plugins (#9096)
* Move data export code out of "importer" directory * Refactoring to allow data export via plugin * Add brief docs framework * Add basic DataExportMixin class * Pass context data through to the serializer * Extract custom serializer * Refactoring * Add builtin plugin for BomExport * More refactoring * Cleanup for UseForm hooks * Allow GET methods in forms * Create new 'exporter' app * Refactor imports * Run cleanup task on boot * Add enumeration for plugin mixin types * Refactor with_mixin call * Generate export options serializer * Pass plugin information through * Offload export functionality to the plugin * Generate output * Download generated file * Refactor frontend code * Generate params for downloading * Pass custom fields through to the plugin * Implement multi-level export for BOM data * Export supplier and manufacturer information * Export substitute data * Remove old BOM exporter * Export part parameter data * Try different app order * Use GET instead of POST request - Less 'dangerous' - no chance of performing a destructive operation * Fix for constructing query parameters - Ignore any undefined values! * Trying something * Revert to POST - Required, other query data are ignored * Fix spelling mistakes * Remove SettingsMixin * Revert python version * Fix for settings.py * Fix missing return * Fix for label mixin code * Run playwright tests in --host mode * Fix for choice field - Prevent empty value if field is required * Remove debug prints * Update table header * Playwright tests for data export * Rename app from "exporter" to "data_exporter" * Add frontend table for export sessions * Updated playwright testing * Fix for unit test * Fix build order unit test * Back to using GET instead of POST - Otherwise, users need POST permissions to export! - A bit of trickery with the forms architecture * Fix remaining unit tests * Implement unit test for BOM export - Including test for custom plugin * Fix unit test * Bump API version * Enhanced playwright tests * Add debug for CI testing * Single unit test only (for debugging) * Fix typo * typo fix * Remove debugs * Docs updates * Revert typo * Update tests * Serializer fix * Fix typo * Offload data export to the background worker - Requires mocking the original request object - Will need some further unit testing! * Refactor existing models into DataOutput - Remove LabelOutput table - Remove ReportOutput table - Remove ExportOutput table - Consolidate into single API endpoint * Remove "output" tables from frontend * Refactor frontend hook to be generic * Frontend now works with background data export * Fix tasks.py * Adjust unit tests * Revert 'plugin_key' to 'plugin' * Improve user checking when printing * Updates * Remove erroneous migration file * Tweak plugin registry * Adjust playwright tests * Refactor data export - Convert into custom hook - Enable for calendar view also * Add playwright tests * Adjust unit testing * Tweak unit tests * Add extra timeout to data export * Fix for RUF045
This commit is contained in:
parent
947a1bcc3a
commit
8d51aa1563
@ -7,7 +7,7 @@ The API schema as documented below is generated using the [drf-spectactular](htt
|
|||||||
|
|
||||||
## API Version
|
## API Version
|
||||||
|
|
||||||
This documentation is for API version: `171`
|
This documentation is for API version: `315`
|
||||||
|
|
||||||
!!! tip "API Schema History"
|
!!! tip "API Schema History"
|
||||||
We track API schema changes, and provide a snapshot of each API schema version in the [API schema repository](https://github.com/inventree/schema/).
|
We track API schema changes, and provide a snapshot of each API schema version in the [API schema repository](https://github.com/inventree/schema/).
|
||||||
|
2
docs/docs/build/bom.md
vendored
2
docs/docs/build/bom.md
vendored
@ -119,7 +119,7 @@ Select a part in the list and click on "Add Substitute" button to confirm.
|
|||||||
|
|
||||||
### Validate BOM
|
### Validate BOM
|
||||||
|
|
||||||
After [adding BOM items manually](#add-bom-item) or [uploading a BOM file](./bom_import.md), you should see the following view:
|
After [adding BOM items manually](#add-bom-item) or uploading a BOM file, you should see the following view:
|
||||||
{% with id="bom_invalid", url="build/bom_invalid.png", description="Invalid BOM View" %}
|
{% with id="bom_invalid", url="build/bom_invalid.png", description="Invalid BOM View" %}
|
||||||
{% include 'img.html' %}
|
{% include 'img.html' %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
44
docs/docs/build/bom_export.md
vendored
44
docs/docs/build/bom_export.md
vendored
@ -1,44 +0,0 @@
|
|||||||
---
|
|
||||||
title: BOM Export
|
|
||||||
---
|
|
||||||
|
|
||||||
## Exporting BOM Data
|
|
||||||
|
|
||||||
|
|
||||||
BOM data can be exported for any given assembly by selecting the *Export BOM* action from the BOM actions menu.
|
|
||||||
|
|
||||||
You will be presented with the *Export BOM* options dialog, shown below:
|
|
||||||
|
|
||||||
{% with id="bom_export", url="build/bom_export.png", description="Export BOM Data" %}
|
|
||||||
{% include 'img.html' %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
### BOM Export Options
|
|
||||||
|
|
||||||
**Format**
|
|
||||||
|
|
||||||
Select the file format for the exported BOM data
|
|
||||||
|
|
||||||
**Multi Level BOM**
|
|
||||||
|
|
||||||
If selected, BOM data will be included for any subassemblies. If not selected, only top level (flat) BOM data will be exported.
|
|
||||||
|
|
||||||
**Levels**
|
|
||||||
|
|
||||||
Define the maximum level of data to export for subassemblies. If set to zero, all levels of subassembly data will be exported.
|
|
||||||
|
|
||||||
**Include Parameter Data**
|
|
||||||
|
|
||||||
Include part parameter data in the exported dataset.
|
|
||||||
|
|
||||||
**Include Stock Data**
|
|
||||||
|
|
||||||
Include part stock level information in the exported dataset.
|
|
||||||
|
|
||||||
**Include Manufacturer Data**
|
|
||||||
|
|
||||||
Include part manufacturer information in the exported dataset.
|
|
||||||
|
|
||||||
**Include Supplier Data**
|
|
||||||
|
|
||||||
Include part supplier information in the exported dataset.
|
|
49
docs/docs/build/bom_import.md
vendored
49
docs/docs/build/bom_import.md
vendored
@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
title: BOM Import
|
|
||||||
---
|
|
||||||
|
|
||||||
## Importing BOM Data
|
|
||||||
|
|
||||||
Uploading a BOM to InvenTree is a three steps process:
|
|
||||||
|
|
||||||
1. Upload BOM file
|
|
||||||
0. Select matching InvenTree fields
|
|
||||||
0. Select matching InvenTree parts.
|
|
||||||
|
|
||||||
To upload a BOM file, navigate to the part/assembly detail page then click on the "BOM" tab. On top of the tab view, click on the <span class='fas fa-edit'></span> icon then, after the page reloads, click on the <span class='fas fa-file-upload'></span> icon.
|
|
||||||
|
|
||||||
The following view will load:
|
|
||||||
{% with id="bom_upload_file", url="build/bom_upload_file.png", description="BOM Upload View" %}
|
|
||||||
{% include 'img.html' %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
#### Upload BOM File
|
|
||||||
|
|
||||||
Click on the "Choose File" button, select your BOM file when prompted then click on the "Upload File" button.
|
|
||||||
|
|
||||||
!!! info "BOM Formats"
|
|
||||||
The following BOM file formats are supported: CSV, TSV, XLS, XLSX, JSON and YAML
|
|
||||||
|
|
||||||
#### Select Fields
|
|
||||||
|
|
||||||
Once the BOM file is uploaded, the following view will load:
|
|
||||||
{% with id="bom_select_fields", url="build/bom_select_fields.png", description="Select Fields View" %}
|
|
||||||
{% include 'img.html' %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
InvenTree will attempt to automatically match the BOM file columns with InvenTree part fields. `Part_Name` is a **required** field for the upload process and moving on to the next step. Specifying the `Part_IPN` field matching is very powerful as it allows to create direct pointers to InvenTree parts.
|
|
||||||
|
|
||||||
Once you have selected the corresponding InvenTree fields, click on the "Submit Selections" button to move on to the next step.
|
|
||||||
|
|
||||||
#### Select Parts
|
|
||||||
|
|
||||||
Once the BOM file columns and InvenTree fields are correctly matched, the following view will load:
|
|
||||||
{% with id="bom_select_parts", url="build/bom_select_parts.png", description="Select Parts View" %}
|
|
||||||
{% include 'img.html' %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
InvenTree automatically tries to match parts from the BOM file with parts in its database. For parts that are found in InvenTree's database, the `Select Part` field selection will automatically point to the matching database part.
|
|
||||||
|
|
||||||
In this view, you can also edit the parts `Reference` and `Quantity` fields.
|
|
||||||
|
|
||||||
Once you have selected the corresponding InvenTree parts, click on the "Submit BOM" button to complete the BOM upload process.
|
|
@ -113,6 +113,7 @@ Supported mixin classes are:
|
|||||||
| [AppMixin](./plugins/app.md) | Integrate additional database tables |
|
| [AppMixin](./plugins/app.md) | Integrate additional database tables |
|
||||||
| [BarcodeMixin](./plugins/barcode.md) | Support custom barcode actions |
|
| [BarcodeMixin](./plugins/barcode.md) | Support custom barcode actions |
|
||||||
| [CurrencyExchangeMixin](./plugins/currency.md) | Custom interfaces for currency exchange rates |
|
| [CurrencyExchangeMixin](./plugins/currency.md) | Custom interfaces for currency exchange rates |
|
||||||
|
| [DataExport](./plugins/export.md) | Customize data export functionality |
|
||||||
| [EventMixin](./plugins/event.md) | Respond to events |
|
| [EventMixin](./plugins/event.md) | Respond to events |
|
||||||
| [LabelPrintingMixin](./plugins/label.md) | Custom label printing support |
|
| [LabelPrintingMixin](./plugins/label.md) | Custom label printing support |
|
||||||
| [LocateMixin](./plugins/locate.md) | Locate and identify stock items |
|
| [LocateMixin](./plugins/locate.md) | Locate and identify stock items |
|
||||||
|
122
docs/docs/extend/plugins/export.md
Normal file
122
docs/docs/extend/plugins/export.md
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
title: Data Export Mixin
|
||||||
|
---
|
||||||
|
|
||||||
|
## DataExportMixin
|
||||||
|
|
||||||
|
The `DataExportMixin` class provides a plugin with the ability to customize the data export process. The [InvenTree API](../../api/api.md) provides an integrated method to export a dataset to a tabulated file. The default export process is generic, and simply exports the data presented via the API in a tabulated file format.
|
||||||
|
|
||||||
|
Custom data export plugins allow this process to be adjusted:
|
||||||
|
|
||||||
|
- Data columns can be added or removed
|
||||||
|
- Rows can be removed or added
|
||||||
|
- Custom calculations or annotations can be performed.
|
||||||
|
|
||||||
|
### Supported Export Types
|
||||||
|
|
||||||
|
Each plugin can dictate which datasets are supported using the `supports_export` method. This allows a plugin to dynamically specify whether it can be selected by the user for a given export session.
|
||||||
|
|
||||||
|
::: plugin.base.integration.DataExport.DataExportMixin.supports_export
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
show_sources: True
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
|
||||||
|
The default implementation returns `True` for all data types.
|
||||||
|
|
||||||
|
### Filename Generation
|
||||||
|
|
||||||
|
The `generate_filename` method constructs a filename for the exported file.
|
||||||
|
|
||||||
|
::: plugin.base.integration.DataExport.DataExportMixin.generate_filename
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
show_sources: True
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
|
||||||
|
### Adjust Columns
|
||||||
|
|
||||||
|
The `update_headers` method allows the plugin to adjust the columns selected to be exported to the file.
|
||||||
|
|
||||||
|
::: plugin.base.integration.DataExport.DataExportMixin.update_headers
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
show_sources: True
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
|
||||||
|
### Queryset Filtering
|
||||||
|
|
||||||
|
The `filter_queryset` method allows the plugin to provide custom filtering to the database query, before it is exported.
|
||||||
|
|
||||||
|
::: plugin.base.integration.DataExport.DataExportMixin.filter_queryset
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
show_sources: True
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
|
||||||
|
### Export Data
|
||||||
|
|
||||||
|
The `export_data` method performs the step of transforming a [Django QuerySet]({% include "django.html" %}/ref/models/querysets/) into a dataset which can be processed by the [tablib](https://tablib.readthedocs.io/en/stable/) library.
|
||||||
|
|
||||||
|
::: plugin.base.integration.DataExport.DataExportMixin.export_data
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
show_sources: True
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
|
||||||
|
Note that the default implementation simply uses the builtin tabulation functionality of the provided serializer class. In most cases, this will be sufficient.
|
||||||
|
|
||||||
|
## Custom Export Options
|
||||||
|
|
||||||
|
To provide the user with custom options to control the behavior of the export process *at the time of export*, the plugin can define a custom serializer class.
|
||||||
|
|
||||||
|
To enable this feature, define an `ExportOptionsSerializer` attribute on the plugin class which points to a DRF serializer class. Refer to the examples below for more information.
|
||||||
|
|
||||||
|
### Builtin Exporter Classes
|
||||||
|
|
||||||
|
InvenTree provides the following builtin data exporter classes.
|
||||||
|
|
||||||
|
### InvenTreeExporter
|
||||||
|
|
||||||
|
A generic exporter class which simply serializes the API output into a data file.
|
||||||
|
|
||||||
|
::: plugin.builtin.exporter.inventree_exporter.InvenTreeExporter
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
show_source: True
|
||||||
|
members: []
|
||||||
|
|
||||||
|
### BOM Exporter
|
||||||
|
|
||||||
|
A custom exporter which only supports [bill of materials](../../build/bom.md) exporting.
|
||||||
|
|
||||||
|
::: plugin.builtin.exporter.bom_exporter.BomExporterPlugin
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
show_source: True
|
||||||
|
members: []
|
||||||
|
|
||||||
|
## Source Code
|
||||||
|
|
||||||
|
The full source code of the `DataExportMixin` class:
|
||||||
|
|
||||||
|
{{ includefile("src/backend/InvenTree/plugin/base/integration/DataExport.py", title="DataExportMixin") }}
|
@ -4,7 +4,7 @@ title: Report Mixin
|
|||||||
|
|
||||||
## ReportMixin
|
## ReportMixin
|
||||||
|
|
||||||
The ReportMixin class provides a plugin with the ability to extend the functionality of custom [report templates](../../report/report.md). A plugin which implements the ReportMixin mixin class can add custom context data to a report template for rendering.
|
The `ReportMixin` class provides a plugin with the ability to extend the functionality of custom [report templates](../../report/report.md). A plugin which implements the ReportMixin mixin class can add custom context data to a report template for rendering.
|
||||||
|
|
||||||
### Add Report Context
|
### Add Report Context
|
||||||
|
|
||||||
|
@ -102,7 +102,7 @@ def check_link(url) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_build_enviroment() -> str:
|
def get_build_environment() -> str:
|
||||||
"""Returns the branch we are currently building on, based on the environment variables of the various CI platforms."""
|
"""Returns the branch we are currently building on, based on the environment variables of the various CI platforms."""
|
||||||
# Check if we are in ReadTheDocs
|
# Check if we are in ReadTheDocs
|
||||||
if os.environ.get('READTHEDOCS') == 'True':
|
if os.environ.get('READTHEDOCS') == 'True':
|
||||||
@ -134,7 +134,7 @@ def define_env(env):
|
|||||||
- FileNotFoundError: If the directory does not exist, or the generated URL is invalid
|
- FileNotFoundError: If the directory does not exist, or the generated URL is invalid
|
||||||
"""
|
"""
|
||||||
if branch == None:
|
if branch == None:
|
||||||
branch = get_build_enviroment()
|
branch = get_build_environment()
|
||||||
|
|
||||||
if dirname.startswith('/'):
|
if dirname.startswith('/'):
|
||||||
dirname = dirname[1:]
|
dirname = dirname[1:]
|
||||||
@ -173,7 +173,7 @@ def define_env(env):
|
|||||||
- FileNotFoundError: If the file does not exist, or the generated URL is invalid
|
- FileNotFoundError: If the file does not exist, or the generated URL is invalid
|
||||||
"""
|
"""
|
||||||
if branch == None:
|
if branch == None:
|
||||||
branch = get_build_enviroment()
|
branch = get_build_environment()
|
||||||
|
|
||||||
if filename.startswith('/'):
|
if filename.startswith('/'):
|
||||||
filename = filename[1:]
|
filename = filename[1:]
|
||||||
|
@ -130,8 +130,6 @@ nav:
|
|||||||
- Allocating Stock: build/allocate.md
|
- Allocating Stock: build/allocate.md
|
||||||
- Example Build Order: build/example.md
|
- Example Build Order: build/example.md
|
||||||
- Bill of Materials: build/bom.md
|
- Bill of Materials: build/bom.md
|
||||||
- Importing BOM Data: build/bom_import.md
|
|
||||||
- Exporting BOM Data: build/bom_export.md
|
|
||||||
- Orders:
|
- Orders:
|
||||||
- Companies: order/company.md
|
- Companies: order/company.md
|
||||||
- Purchase Orders: order/purchase_order.md
|
- Purchase Orders: order/purchase_order.md
|
||||||
@ -208,6 +206,7 @@ nav:
|
|||||||
- App Mixin: extend/plugins/app.md
|
- App Mixin: extend/plugins/app.md
|
||||||
- Barcode Mixin: extend/plugins/barcode.md
|
- Barcode Mixin: extend/plugins/barcode.md
|
||||||
- Currency Mixin: extend/plugins/currency.md
|
- Currency Mixin: extend/plugins/currency.md
|
||||||
|
- Data Export Mixin: extend/plugins/export.md
|
||||||
- Event Mixin: extend/plugins/event.md
|
- Event Mixin: extend/plugins/event.md
|
||||||
- Icon Pack Mixin: extend/plugins/icon.md
|
- Icon Pack Mixin: extend/plugins/icon.md
|
||||||
- Label Printing Mixin: extend/plugins/label.md
|
- Label Printing Mixin: extend/plugins/label.md
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 325
|
INVENTREE_API_VERSION = 326
|
||||||
|
|
||||||
"""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 = """
|
||||||
|
|
||||||
|
v326 - 2025-03-18 : https://github.com/inventree/InvenTree/pull/9096
|
||||||
|
- Overhaul the data-export API functionality
|
||||||
|
- Allow customization of data exporting via plugins
|
||||||
|
- Consolidate LabelOutput and ReportOutput API endpoints into single DataOutput endpoint
|
||||||
|
|
||||||
v325 - 2024-03-17 : https://github.com/inventree/InvenTree/pull/9244
|
v325 - 2024-03-17 : https://github.com/inventree/InvenTree/pull/9244
|
||||||
- Adds the option for superusers to list all user tokens
|
- Adds the option for superusers to list all user tokens
|
||||||
- Make list endpoints sortable, filterable and searchable
|
- Make list endpoints sortable, filterable and searchable
|
||||||
|
@ -88,6 +88,8 @@ class InvenTreeConfig(AppConfig):
|
|||||||
'InvenTree.tasks.delete_expired_sessions',
|
'InvenTree.tasks.delete_expired_sessions',
|
||||||
'stock.tasks.delete_old_stock_items',
|
'stock.tasks.delete_old_stock_items',
|
||||||
'label.tasks.cleanup_old_label_outputs',
|
'label.tasks.cleanup_old_label_outputs',
|
||||||
|
'report.tasks.cleanup_old_report_outputs',
|
||||||
|
'data_exporter.tasks.cleanup_old_export_outputs',
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -22,7 +22,7 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
|||||||
|
|
||||||
def get_rates(self, **kwargs) -> dict:
|
def get_rates(self, **kwargs) -> dict:
|
||||||
"""Set the requested currency codes and get rates."""
|
"""Set the requested currency codes and get rates."""
|
||||||
from plugin import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
base_currency = kwargs.get('base_currency', currency_code_default())
|
base_currency = kwargs.get('base_currency', currency_code_default())
|
||||||
symbols = kwargs.get('symbols', currency_codes())
|
symbols = kwargs.get('symbols', currency_codes())
|
||||||
@ -34,7 +34,9 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
|||||||
|
|
||||||
if not plugin:
|
if not plugin:
|
||||||
# Find the first active currency exchange plugin
|
# Find the first active currency exchange plugin
|
||||||
plugins = registry.with_mixin('currencyexchange', active=True)
|
plugins = registry.with_mixin(
|
||||||
|
PluginMixinEnum.CURRENCY_EXCHANGE, active=True
|
||||||
|
)
|
||||||
|
|
||||||
if len(plugins) > 0:
|
if len(plugins) > 0:
|
||||||
plugin = plugins[0]
|
plugin = plugins[0]
|
||||||
|
@ -384,9 +384,14 @@ def WrapWithQuotes(text, quote='"'):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def GetExportFormats():
|
def GetExportOptions() -> list:
|
||||||
|
"""Return a set of allowable import / export file formats."""
|
||||||
|
return [['csv', 'CSV'], ['xlsx', 'Excel'], ['tsv', 'TSV']]
|
||||||
|
|
||||||
|
|
||||||
|
def GetExportFormats() -> list:
|
||||||
"""Return a list of allowable file formats for importing or exporting tabular data."""
|
"""Return a list of allowable file formats for importing or exporting tabular data."""
|
||||||
return ['csv', 'xlsx', 'tsv', 'json']
|
return [opt[0] for opt in GetExportOptions()]
|
||||||
|
|
||||||
|
|
||||||
def DownloadFile(
|
def DownloadFile(
|
||||||
@ -437,14 +442,14 @@ def increment_serial_number(serial, part=None):
|
|||||||
incremented value, or None if incrementing could not be performed.
|
incremented value, or None if incrementing could not be performed.
|
||||||
"""
|
"""
|
||||||
from InvenTree.exceptions import log_error
|
from InvenTree.exceptions import log_error
|
||||||
from plugin.registry import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
# Ensure we start with a string value
|
# Ensure we start with a string value
|
||||||
if serial is not None:
|
if serial is not None:
|
||||||
serial = str(serial).strip()
|
serial = str(serial).strip()
|
||||||
|
|
||||||
# First, let any plugins attempt to increment the serial number
|
# First, let any plugins attempt to increment the serial number
|
||||||
for plugin in registry.with_mixin('validation'):
|
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||||
try:
|
try:
|
||||||
if not hasattr(plugin, 'increment_serial_number'):
|
if not hasattr(plugin, 'increment_serial_number'):
|
||||||
continue
|
continue
|
||||||
|
@ -5,6 +5,8 @@ from django.core.exceptions import FieldDoesNotExist
|
|||||||
from rest_framework import generics, mixins, status
|
from rest_framework import generics, mixins, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
import data_exporter.mixins
|
||||||
|
import importer.mixins
|
||||||
from InvenTree.fields import InvenTreeNotesField
|
from InvenTree.fields import InvenTreeNotesField
|
||||||
from InvenTree.helpers import (
|
from InvenTree.helpers import (
|
||||||
clean_markdown,
|
clean_markdown,
|
||||||
@ -197,3 +199,10 @@ class RetrieveDestroyAPI(generics.RetrieveDestroyAPIView):
|
|||||||
|
|
||||||
class UpdateAPI(CleanMixin, generics.UpdateAPIView):
|
class UpdateAPI(CleanMixin, generics.UpdateAPIView):
|
||||||
"""View for update API."""
|
"""View for update API."""
|
||||||
|
|
||||||
|
|
||||||
|
class DataImportExportSerializerMixin(
|
||||||
|
data_exporter.mixins.DataExportSerializerMixin,
|
||||||
|
importer.mixins.DataImportSerializerMixin,
|
||||||
|
):
|
||||||
|
"""Mixin class for adding data import/export functionality to a DRF serializer."""
|
||||||
|
@ -87,11 +87,11 @@ class PluginValidationMixin(DiffMixin):
|
|||||||
|
|
||||||
def run_plugin_validation(self):
|
def run_plugin_validation(self):
|
||||||
"""Throw this model against the plugin validation interface."""
|
"""Throw this model against the plugin validation interface."""
|
||||||
from plugin.registry import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
deltas = self.get_field_deltas()
|
deltas = self.get_field_deltas()
|
||||||
|
|
||||||
for plugin in registry.with_mixin('validation'):
|
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||||
try:
|
try:
|
||||||
if plugin.validate_model_instance(self, deltas=deltas) is True:
|
if plugin.validate_model_instance(self, deltas=deltas) is True:
|
||||||
return
|
return
|
||||||
@ -130,9 +130,9 @@ class PluginValidationMixin(DiffMixin):
|
|||||||
Note: Each plugin may raise a ValidationError to prevent deletion.
|
Note: Each plugin may raise a ValidationError to prevent deletion.
|
||||||
"""
|
"""
|
||||||
from InvenTree.exceptions import log_error
|
from InvenTree.exceptions import log_error
|
||||||
from plugin.registry import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
for plugin in registry.with_mixin('validation'):
|
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||||
try:
|
try:
|
||||||
plugin.validate_model_deletion(self)
|
plugin.validate_model_deletion(self)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
|
@ -250,14 +250,15 @@ INSTALLED_APPS = [
|
|||||||
# InvenTree apps
|
# InvenTree apps
|
||||||
'build.apps.BuildConfig',
|
'build.apps.BuildConfig',
|
||||||
'common.apps.CommonConfig',
|
'common.apps.CommonConfig',
|
||||||
'company.apps.CompanyConfig',
|
|
||||||
'plugin.apps.PluginAppConfig', # Plugin app runs before all apps that depend on the isPluginRegistryLoaded function
|
'plugin.apps.PluginAppConfig', # Plugin app runs before all apps that depend on the isPluginRegistryLoaded function
|
||||||
|
'company.apps.CompanyConfig',
|
||||||
'order.apps.OrderConfig',
|
'order.apps.OrderConfig',
|
||||||
'part.apps.PartConfig',
|
'part.apps.PartConfig',
|
||||||
'report.apps.ReportConfig',
|
'report.apps.ReportConfig',
|
||||||
'stock.apps.StockConfig',
|
'stock.apps.StockConfig',
|
||||||
'users.apps.UsersConfig',
|
'users.apps.UsersConfig',
|
||||||
'machine.apps.MachineConfig',
|
'machine.apps.MachineConfig',
|
||||||
|
'data_exporter.apps.DataExporterConfig',
|
||||||
'importer.apps.ImporterConfig',
|
'importer.apps.ImporterConfig',
|
||||||
'web',
|
'web',
|
||||||
'generic',
|
'generic',
|
||||||
|
@ -280,14 +280,15 @@ class ScheduledTask:
|
|||||||
interval: str
|
interval: str
|
||||||
minutes: Optional[int] = None
|
minutes: Optional[int] = None
|
||||||
|
|
||||||
MINUTES = 'I'
|
MINUTES: str = 'I'
|
||||||
HOURLY = 'H'
|
HOURLY: str = 'H'
|
||||||
DAILY = 'D'
|
DAILY: str = 'D'
|
||||||
WEEKLY = 'W'
|
WEEKLY: str = 'W'
|
||||||
MONTHLY = 'M'
|
MONTHLY: str = 'M'
|
||||||
QUARTERLY = 'Q'
|
QUARTERLY: str = 'Q'
|
||||||
YEARLY = 'Y'
|
YEARLY: str = 'Y'
|
||||||
TYPE = [MINUTES, HOURLY, DAILY, WEEKLY, MONTHLY, QUARTERLY, YEARLY]
|
|
||||||
|
TYPE: tuple[str] = (MINUTES, HOURLY, DAILY, WEEKLY, MONTHLY, QUARTERLY, YEARLY)
|
||||||
|
|
||||||
|
|
||||||
class TaskRegister:
|
class TaskRegister:
|
||||||
|
@ -486,7 +486,13 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def download_file(
|
def download_file(
|
||||||
self, url, data, expected_code=None, expected_fn=None, decode=True, **kwargs
|
self,
|
||||||
|
url,
|
||||||
|
data=None,
|
||||||
|
expected_code=None,
|
||||||
|
expected_fn=None,
|
||||||
|
decode=True,
|
||||||
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Download a file from the server, and return an in-memory file."""
|
"""Download a file from the server, and return an in-memory file."""
|
||||||
response = self.client.get(url, data=data, format='json')
|
response = self.client.get(url, data=data, format='json')
|
||||||
@ -502,9 +508,11 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
|||||||
# Extract filename
|
# Extract filename
|
||||||
disposition = response.headers['Content-Disposition']
|
disposition = response.headers['Content-Disposition']
|
||||||
|
|
||||||
result = re.search(r'attachment; filename="([\w\d\-.]+)"', disposition)
|
result = re.search(
|
||||||
|
r'(attachment|inline); filename=[\'"]([\w\d\-.]+)[\'"]', disposition
|
||||||
|
)
|
||||||
|
|
||||||
fn = result.groups()[0]
|
fn = result.groups()[1]
|
||||||
|
|
||||||
if expected_fn is not None:
|
if expected_fn is not None:
|
||||||
self.assertRegex(fn, expected_fn)
|
self.assertRegex(fn, expected_fn)
|
||||||
@ -524,6 +532,72 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
|||||||
|
|
||||||
return file
|
return file
|
||||||
|
|
||||||
|
def export_data(
|
||||||
|
self,
|
||||||
|
url,
|
||||||
|
params=None,
|
||||||
|
export_format='csv',
|
||||||
|
export_plugin='inventree-exporter',
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Perform a data export operation against the provided URL.
|
||||||
|
|
||||||
|
Uses the 'data_exporter' functionality to override the POST response.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
url: URL to perform the export operation against
|
||||||
|
params: Dictionary of parameters to pass to the export operation
|
||||||
|
export_format: Export format (default = 'csv')
|
||||||
|
export_plugin: Export plugin (default = 'inventree-exporter')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A file object containing the exported dataset
|
||||||
|
"""
|
||||||
|
# Ensure that the plugin registry is up-to-date
|
||||||
|
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||||
|
|
||||||
|
download = kwargs.pop('download', True)
|
||||||
|
expected_code = kwargs.pop('expected_code', 200)
|
||||||
|
|
||||||
|
if not params:
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
params = {
|
||||||
|
**params,
|
||||||
|
'export': True,
|
||||||
|
'export_format': export_format,
|
||||||
|
'export_plugin': export_plugin,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add in any other export specific kwargs
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if key.startswith('export_'):
|
||||||
|
params[key] = value
|
||||||
|
|
||||||
|
# Append URL params
|
||||||
|
url += '?' + '&'.join([f'{key}={value}' for key, value in params.items()])
|
||||||
|
|
||||||
|
response = self.client.get(url, data=None, format='json')
|
||||||
|
self.check_response(url, response, expected_code=expected_code)
|
||||||
|
|
||||||
|
# Check that the response is of the correct type
|
||||||
|
data = response.data
|
||||||
|
|
||||||
|
if expected_code != 200:
|
||||||
|
# Response failed
|
||||||
|
return response.data
|
||||||
|
|
||||||
|
self.assertEqual(data['plugin'], export_plugin)
|
||||||
|
self.assertTrue(data['complete'])
|
||||||
|
filename = data.get('output')
|
||||||
|
self.assertIsNotNone(filename)
|
||||||
|
|
||||||
|
if download:
|
||||||
|
return self.download_file(filename, **kwargs)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return response.data
|
||||||
|
|
||||||
def process_csv(
|
def process_csv(
|
||||||
self,
|
self,
|
||||||
file_object,
|
file_object,
|
||||||
|
@ -16,8 +16,8 @@ import common.models
|
|||||||
import part.models as part_models
|
import part.models as part_models
|
||||||
from build.models import Build, BuildItem, BuildLine
|
from build.models import Build, BuildItem, BuildLine
|
||||||
from build.status_codes import BuildStatus, BuildStatusGroups
|
from build.status_codes import BuildStatus, BuildStatusGroups
|
||||||
|
from data_exporter.mixins import DataExportViewMixin
|
||||||
from generic.states.api import StatusView
|
from generic.states.api import StatusView
|
||||||
from importer.mixins import DataExportViewMixin
|
|
||||||
from InvenTree.api import BulkDeleteMixin, MetadataView
|
from InvenTree.api import BulkDeleteMixin, MetadataView
|
||||||
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS, InvenTreeDateFilter
|
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS, InvenTreeDateFilter
|
||||||
from InvenTree.helpers import isNull, str2bool
|
from InvenTree.helpers import isNull, str2bool
|
||||||
@ -388,7 +388,7 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
|
|||||||
kwargs['part_detail'] = part_detail
|
kwargs['part_detail'] = part_detail
|
||||||
kwargs['create'] = True
|
kwargs['create'] = True
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class BuildDetail(BuildMixin, RetrieveUpdateDestroyAPI):
|
class BuildDetail(BuildMixin, RetrieveUpdateDestroyAPI):
|
||||||
@ -529,7 +529,7 @@ class BuildLineEndpoint:
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_source_build(self) -> Build:
|
def get_source_build(self) -> Build:
|
||||||
"""Return the source Build object for the BuildLine queryset.
|
"""Return the source Build object for the BuildLine queryset.
|
||||||
@ -838,7 +838,7 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Override the queryset method, to perform custom prefetch."""
|
"""Override the queryset method, to perform custom prefetch."""
|
||||||
|
@ -31,7 +31,7 @@ import part.serializers as part_serializers
|
|||||||
from common.serializers import ProjectCodeSerializer
|
from common.serializers import ProjectCodeSerializer
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
||||||
from importer.mixins import DataImportExportSerializerMixin
|
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||||
from InvenTree.ready import isGeneratingSchema
|
from InvenTree.ready import isGeneratingSchema
|
||||||
from InvenTree.serializers import (
|
from InvenTree.serializers import (
|
||||||
InvenTreeDecimalField,
|
InvenTreeDecimalField,
|
||||||
|
@ -449,7 +449,6 @@ class BuildTest(BuildAPITest):
|
|||||||
'Build Status',
|
'Build Status',
|
||||||
'Completed items',
|
'Completed items',
|
||||||
'Batch Code',
|
'Batch Code',
|
||||||
'Notes',
|
|
||||||
'Description',
|
'Description',
|
||||||
'Part',
|
'Part',
|
||||||
'Part Name',
|
'Part Name',
|
||||||
@ -459,9 +458,9 @@ class BuildTest(BuildAPITest):
|
|||||||
|
|
||||||
excluded_cols = ['lft', 'rght', 'tree_id', 'level', 'metadata']
|
excluded_cols = ['lft', 'rght', 'tree_id', 'level', 'metadata']
|
||||||
|
|
||||||
with self.download_file(reverse('api-build-list'), {'export': 'csv'}) as file:
|
with self.export_data(reverse('api-build-list')) as data_file:
|
||||||
data = self.process_csv(
|
data = self.process_csv(
|
||||||
file,
|
data_file,
|
||||||
required_cols=required_cols,
|
required_cols=required_cols,
|
||||||
excluded_cols=excluded_cols,
|
excluded_cols=excluded_cols,
|
||||||
required_rows=Build.objects.count(),
|
required_rows=Build.objects.count(),
|
||||||
|
@ -33,6 +33,15 @@ class AttachmentAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ('content_type', 'comment')
|
search_fields = ('content_type', 'comment')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(common.models.DataOutput)
|
||||||
|
class DataOutputAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for DataOutput objects."""
|
||||||
|
|
||||||
|
list_display = ('user', 'created', 'output_type', 'output')
|
||||||
|
|
||||||
|
list_filter = ('user', 'output_type')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(common.models.BarcodeScanResult)
|
@admin.register(common.models.BarcodeScanResult)
|
||||||
class BarcodeScanResultAdmin(admin.ModelAdmin):
|
class BarcodeScanResultAdmin(admin.ModelAdmin):
|
||||||
"""Admin interface for BarcodeScanResult objects."""
|
"""Admin interface for BarcodeScanResult objects."""
|
||||||
|
@ -31,8 +31,8 @@ import common.serializers
|
|||||||
import InvenTree.conversion
|
import InvenTree.conversion
|
||||||
from common.icons import get_icon_packs
|
from common.icons import get_icon_packs
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
|
from data_exporter.mixins import DataExportViewMixin
|
||||||
from generic.states.api import urlpattern as generic_states_api_urls
|
from generic.states.api import urlpattern as generic_states_api_urls
|
||||||
from importer.mixins import DataExportViewMixin
|
|
||||||
from InvenTree.api import BulkDeleteMixin, MetadataView
|
from InvenTree.api import BulkDeleteMixin, MetadataView
|
||||||
from InvenTree.config import CONFIG_LOOKUPS
|
from InvenTree.config import CONFIG_LOOKUPS
|
||||||
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
|
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
|
||||||
@ -854,6 +854,25 @@ class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI):
|
|||||||
"""Detail view for a SelectionEntry object."""
|
"""Detail view for a SelectionEntry object."""
|
||||||
|
|
||||||
|
|
||||||
|
class DataOutputEndpoint:
|
||||||
|
"""Mixin class for DataOutput endpoints."""
|
||||||
|
|
||||||
|
queryset = common.models.DataOutput.objects.all()
|
||||||
|
serializer_class = common.serializers.DataOutputSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class DataOutputList(DataOutputEndpoint, BulkDeleteMixin, ListAPI):
|
||||||
|
"""List view for DataOutput objects."""
|
||||||
|
|
||||||
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
ordering_fields = ['pk', 'user', 'plugin', 'output_type', 'created']
|
||||||
|
|
||||||
|
|
||||||
|
class DataOutputDetail(DataOutputEndpoint, RetrieveAPI):
|
||||||
|
"""Detail view for a DataOutput object."""
|
||||||
|
|
||||||
|
|
||||||
selection_urls = [
|
selection_urls = [
|
||||||
path(
|
path(
|
||||||
'<int:pk>/',
|
'<int:pk>/',
|
||||||
@ -1096,6 +1115,16 @@ common_api_urls = [
|
|||||||
path('icons/', IconList.as_view(), name='api-icon-list'),
|
path('icons/', IconList.as_view(), name='api-icon-list'),
|
||||||
# Selection lists
|
# Selection lists
|
||||||
path('selection/', include(selection_urls)),
|
path('selection/', include(selection_urls)),
|
||||||
|
# Data output
|
||||||
|
path(
|
||||||
|
'data-output/',
|
||||||
|
include([
|
||||||
|
path(
|
||||||
|
'<int:pk>/', DataOutputDetail.as_view(), name='api-data-output-detail'
|
||||||
|
),
|
||||||
|
path('', DataOutputList.as_view(), name='api-data-output-list'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
admin_api_urls = [
|
admin_api_urls = [
|
||||||
|
@ -132,9 +132,9 @@ def validate_currency_codes(value):
|
|||||||
def currency_exchange_plugins() -> Optional[list]:
|
def currency_exchange_plugins() -> Optional[list]:
|
||||||
"""Return a list of plugin choices which can be used for currency exchange."""
|
"""Return a list of plugin choices which can be used for currency exchange."""
|
||||||
try:
|
try:
|
||||||
from plugin import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
plugs = registry.with_mixin('currencyexchange', active=True)
|
plugs = registry.with_mixin(PluginMixinEnum.CURRENCY_EXCHANGE, active=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
plugs = []
|
plugs = []
|
||||||
|
|
||||||
|
@ -73,9 +73,9 @@ def get_icon_packs():
|
|||||||
]
|
]
|
||||||
|
|
||||||
from InvenTree.exceptions import log_error
|
from InvenTree.exceptions import log_error
|
||||||
from plugin import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
for plugin in registry.with_mixin('icon_pack', active=True):
|
for plugin in registry.with_mixin(PluginMixinEnum.ICON_PACK, active=True):
|
||||||
try:
|
try:
|
||||||
icon_packs.extend(plugin.icon_packs())
|
icon_packs.extend(plugin.icon_packs())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
58
src/backend/InvenTree/common/migrations/0037_dataoutput.py
Normal file
58
src/backend/InvenTree/common/migrations/0037_dataoutput.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-03-11 08:22
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("common", "0036_alter_attachment_link"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DataOutput",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created", models.DateField(auto_now_add=True)),
|
||||||
|
("total", models.PositiveIntegerField(default=1)),
|
||||||
|
("progress", models.PositiveIntegerField(default=0)),
|
||||||
|
("complete", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"output_type",
|
||||||
|
models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"template_name",
|
||||||
|
models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
("plugin", models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
(
|
||||||
|
"output",
|
||||||
|
models.FileField(blank=True, null=True, upload_to="data_output"),
|
||||||
|
),
|
||||||
|
("errors", models.JSONField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -4,6 +4,7 @@ These models are 'generic' and do not fit a particular business logic object.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import enum
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
@ -2312,3 +2313,56 @@ class BarcodeScanResult(InvenTree.models.InvenTreeModel):
|
|||||||
help_text=_('Was the barcode scan successful?'),
|
help_text=_('Was the barcode scan successful?'),
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataOutput(models.Model):
|
||||||
|
"""Model for storing generated data output from various processes.
|
||||||
|
|
||||||
|
This model is intended for storing data files which are generated by various processes,
|
||||||
|
and need to be retained for future use (e.g. download by the user).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
created: Date and time that the data output was created
|
||||||
|
user: User who created the data output (if applicable)
|
||||||
|
total: Total number of items / records in the data output
|
||||||
|
progress: Current progress of the data output generation process
|
||||||
|
complete: Has the data output generation process completed?
|
||||||
|
output_type: The type of data output generated (e.g. 'label', 'report', etc)
|
||||||
|
template_name: Name of the template used to generate the data output (if applicable)
|
||||||
|
plugin: Key for the plugin which generated the data output (if applicable)
|
||||||
|
output: File field for storing the generated file
|
||||||
|
errors: JSON field for storing any errors generated during the data output generation process
|
||||||
|
"""
|
||||||
|
|
||||||
|
class DataOutputTypes(str, enum.Enum):
|
||||||
|
"""Enum for data output types."""
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return the string representation of the data output type."""
|
||||||
|
return str(self.value)
|
||||||
|
|
||||||
|
LABEL = 'label'
|
||||||
|
REPORT = 'report'
|
||||||
|
EXPORT = 'export'
|
||||||
|
|
||||||
|
created = models.DateField(auto_now_add=True, editable=False)
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User, on_delete=models.SET_NULL, blank=True, null=True, related_name='+'
|
||||||
|
)
|
||||||
|
|
||||||
|
total = models.PositiveIntegerField(default=1)
|
||||||
|
|
||||||
|
progress = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
complete = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
output_type = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
|
||||||
|
template_name = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
|
||||||
|
plugin = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
|
||||||
|
output = models.FileField(upload_to='data_output', blank=True, null=True)
|
||||||
|
|
||||||
|
errors = models.JSONField(blank=True, null=True)
|
||||||
|
@ -15,10 +15,10 @@ from taggit.serializers import TagListSerializerField
|
|||||||
import common.models as common_models
|
import common.models as common_models
|
||||||
import common.validators
|
import common.validators
|
||||||
import generic.states.custom
|
import generic.states.custom
|
||||||
from importer.mixins import DataImportExportSerializerMixin
|
|
||||||
from importer.registry import register_importer
|
from importer.registry import register_importer
|
||||||
from InvenTree.helpers import get_objectreference
|
from InvenTree.helpers import get_objectreference
|
||||||
from InvenTree.helpers_model import construct_absolute_url
|
from InvenTree.helpers_model import construct_absolute_url
|
||||||
|
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||||
from InvenTree.serializers import (
|
from InvenTree.serializers import (
|
||||||
InvenTreeAttachmentSerializerField,
|
InvenTreeAttachmentSerializerField,
|
||||||
InvenTreeImageSerializerField,
|
InvenTreeImageSerializerField,
|
||||||
@ -757,7 +757,7 @@ class SelectionListSerializer(InvenTreeModelSerializer):
|
|||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
"""Update an existing selection list. Save the choices separately."""
|
"""Update an existing selection list. Save the choices separately."""
|
||||||
inst_mapping = {inst.id: inst for inst in instance.entries.all()}
|
inst_mapping = {inst.id: inst for inst in instance.entries.all()}
|
||||||
exsising_ids = {a.get('id') for a in self._choices_validated}
|
existing_ids = {a.get('id') for a in self._choices_validated}
|
||||||
|
|
||||||
# Perform creations and updates.
|
# Perform creations and updates.
|
||||||
ret = []
|
ret = []
|
||||||
@ -772,7 +772,7 @@ class SelectionListSerializer(InvenTreeModelSerializer):
|
|||||||
ret.append(SelectionEntrySerializer().update(inst, data))
|
ret.append(SelectionEntrySerializer().update(inst, data))
|
||||||
|
|
||||||
# Perform deletions.
|
# Perform deletions.
|
||||||
for entry_id in inst_mapping.keys() - exsising_ids:
|
for entry_id in inst_mapping.keys() - existing_ids:
|
||||||
inst_mapping[entry_id].delete()
|
inst_mapping[entry_id].delete()
|
||||||
|
|
||||||
return super().update(instance, validated_data)
|
return super().update(instance, validated_data)
|
||||||
@ -783,3 +783,32 @@ class SelectionListSerializer(InvenTreeModelSerializer):
|
|||||||
if self.instance and self.instance.locked:
|
if self.instance and self.instance.locked:
|
||||||
raise serializers.ValidationError({'locked': _('Selection list is locked')})
|
raise serializers.ValidationError({'locked': _('Selection list is locked')})
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class DataOutputSerializer(InvenTreeModelSerializer):
|
||||||
|
"""Serializer for the DataOutput model."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Meta options for DataOutputSerializer."""
|
||||||
|
|
||||||
|
model = common_models.DataOutput
|
||||||
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'created',
|
||||||
|
'user',
|
||||||
|
'user_detail',
|
||||||
|
'total',
|
||||||
|
'progress',
|
||||||
|
'complete',
|
||||||
|
'output_type',
|
||||||
|
'template_name',
|
||||||
|
'plugin',
|
||||||
|
'output',
|
||||||
|
'errors',
|
||||||
|
]
|
||||||
|
|
||||||
|
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
||||||
|
|
||||||
|
output = InvenTreeAttachmentSerializerField(
|
||||||
|
required=False, allow_null=True, read_only=True
|
||||||
|
)
|
||||||
|
@ -110,9 +110,9 @@ def reload_plugin_registry(setting):
|
|||||||
def barcode_plugins() -> list:
|
def barcode_plugins() -> list:
|
||||||
"""Return a list of plugin choices which can be used for barcode generation."""
|
"""Return a list of plugin choices which can be used for barcode generation."""
|
||||||
try:
|
try:
|
||||||
from plugin import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
plugins = registry.with_mixin('barcode', active=True)
|
plugins = registry.with_mixin(PluginMixinEnum.BARCODE, active=True)
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
plugins = []
|
plugins = []
|
||||||
|
|
||||||
|
@ -4,13 +4,13 @@ from django.core.validators import MinValueValidator
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from common.setting.type import InvenTreeSettingsKeyType
|
from common.setting.type import InvenTreeSettingsKeyType
|
||||||
from plugin import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
|
|
||||||
def label_printer_options():
|
def label_printer_options():
|
||||||
"""Build a list of available label printer options."""
|
"""Build a list of available label printer options."""
|
||||||
printers = []
|
printers = []
|
||||||
label_printer_plugins = registry.with_mixin('labels')
|
label_printer_plugins = registry.with_mixin(PluginMixinEnum.LABELS)
|
||||||
if label_printer_plugins:
|
if label_printer_plugins:
|
||||||
printers.extend([
|
printers.extend([
|
||||||
(p.slug, p.name + ' - ' + p.human_name) for p in label_printer_plugins
|
(p.slug, p.name + ' - ' + p.human_name) for p in label_printer_plugins
|
||||||
|
@ -12,6 +12,7 @@ import feedparser
|
|||||||
import requests
|
import requests
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
|
import common.models
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
from InvenTree.helpers_model import getModelsWithMixin
|
from InvenTree.helpers_model import getModelsWithMixin
|
||||||
from InvenTree.models import InvenTreeNotesMixin
|
from InvenTree.models import InvenTreeNotesMixin
|
||||||
@ -20,6 +21,17 @@ from InvenTree.tasks import ScheduledTask, scheduled_task
|
|||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
|
def cleanup_old_data_outputs():
|
||||||
|
"""Remove old data outputs from the database."""
|
||||||
|
# Remove any outputs which are older than 5 days
|
||||||
|
# Note: Remove them individually, to ensure that the files are removed too
|
||||||
|
threshold = InvenTree.helpers.current_date() - timedelta(days=5)
|
||||||
|
|
||||||
|
for output in common.models.DataOutput.objects.filter(created__lte=threshold):
|
||||||
|
output.delete()
|
||||||
|
|
||||||
|
|
||||||
@scheduled_task(ScheduledTask.DAILY)
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
def delete_old_notifications():
|
def delete_old_notifications():
|
||||||
"""Remove old notifications from the database.
|
"""Remove old notifications from the database.
|
||||||
|
@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django_filters import rest_framework as rest_filters
|
from django_filters import rest_framework as rest_filters
|
||||||
|
|
||||||
import part.models
|
import part.models
|
||||||
from importer.mixins import DataExportViewMixin
|
from data_exporter.mixins import DataExportViewMixin
|
||||||
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
|
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
|
||||||
from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
|
from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
|
||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
@ -176,7 +176,7 @@ class ManufacturerPartList(DataExportViewMixin, ListCreateDestroyAPIView):
|
|||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
|
||||||
@ -245,7 +245,7 @@ class ManufacturerPartParameterList(ListCreateDestroyAPIView):
|
|||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
|
||||||
@ -355,7 +355,7 @@ class SupplierPartMixin:
|
|||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class SupplierPartList(
|
class SupplierPartList(
|
||||||
@ -467,7 +467,7 @@ class SupplierPriceBreakList(ListCreateAPI):
|
|||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||||
|
|
||||||
|
@ -12,8 +12,8 @@ from taggit.serializers import TagListSerializerField
|
|||||||
import company.filters
|
import company.filters
|
||||||
import part.filters
|
import part.filters
|
||||||
import part.serializers as part_serializers
|
import part.serializers as part_serializers
|
||||||
from importer.mixins import DataImportExportSerializerMixin
|
|
||||||
from importer.registry import register_importer
|
from importer.registry import register_importer
|
||||||
|
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||||
from InvenTree.ready import isGeneratingSchema
|
from InvenTree.ready import isGeneratingSchema
|
||||||
from InvenTree.serializers import (
|
from InvenTree.serializers import (
|
||||||
InvenTreeCurrencySerializer,
|
InvenTreeCurrencySerializer,
|
||||||
|
0
src/backend/InvenTree/data_exporter/__init__.py
Normal file
0
src/backend/InvenTree/data_exporter/__init__.py
Normal file
25
src/backend/InvenTree/data_exporter/apps.py
Normal file
25
src/backend/InvenTree/data_exporter/apps.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
"""Config options for the exporter app."""
|
||||||
|
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DataExporterConfig(AppConfig):
|
||||||
|
"""Configuration class for the 'exporter' app."""
|
||||||
|
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'data_exporter'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
"""Run any code that needs to be executed when the app is loaded."""
|
||||||
|
super().ready()
|
||||||
|
|
||||||
|
self.cleanup()
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Cleanup any old export files."""
|
||||||
|
try:
|
||||||
|
from data_exporter.tasks import cleanup_old_export_outputs
|
||||||
|
|
||||||
|
cleanup_old_export_outputs()
|
||||||
|
except Exception:
|
||||||
|
pass
|
467
src/backend/InvenTree/data_exporter/mixins.py
Normal file
467
src/backend/InvenTree/data_exporter/mixins.py
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
"""Mixin classes for the exporter app."""
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
import tablib
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from taggit.serializers import TagListSerializerField
|
||||||
|
|
||||||
|
import data_exporter.serializers
|
||||||
|
import data_exporter.tasks
|
||||||
|
import InvenTree.exceptions
|
||||||
|
from common.models import DataOutput
|
||||||
|
from InvenTree.helpers import str2bool
|
||||||
|
from InvenTree.tasks import offload_task
|
||||||
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
|
||||||
|
class DataExportSerializerMixin:
|
||||||
|
"""Mixin class for adding data export functionality to a DRF serializer.
|
||||||
|
|
||||||
|
Provides generic functionality to take the output of a serializer and export it to a file.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
export_only_fields: List of field names which are only used during data export
|
||||||
|
export_exclude_fields: List of field names which are excluded during data export
|
||||||
|
export_child_fields: List of child fields which are exported (using dot notation)
|
||||||
|
"""
|
||||||
|
|
||||||
|
export_only_fields = []
|
||||||
|
export_exclude_fields = []
|
||||||
|
export_child_fields = []
|
||||||
|
|
||||||
|
def get_export_only_fields(self, **kwargs) -> list:
|
||||||
|
"""Return the list of field names which are only used during data export."""
|
||||||
|
return self.export_only_fields
|
||||||
|
|
||||||
|
def get_export_exclude_fields(self, **kwargs) -> list:
|
||||||
|
"""Return the list of field names which are excluded during data export."""
|
||||||
|
return self.export_exclude_fields
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Initialise the DataExportSerializerMixin.
|
||||||
|
|
||||||
|
Determine if the serializer is being used for data export,
|
||||||
|
and if so, adjust the serializer fields accordingly.
|
||||||
|
"""
|
||||||
|
exporting = kwargs.pop('exporting', False)
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Cache the request object
|
||||||
|
self.request = self.context.get('request')
|
||||||
|
|
||||||
|
if exporting:
|
||||||
|
# Exclude fields which are not required for data export
|
||||||
|
for field in self.get_export_exclude_fields(**kwargs):
|
||||||
|
self.fields.pop(field, None)
|
||||||
|
else:
|
||||||
|
# Exclude fields which are only used for data export
|
||||||
|
for field in self.get_export_only_fields(**kwargs):
|
||||||
|
self.fields.pop(field, None)
|
||||||
|
|
||||||
|
def get_exportable_fields(self) -> dict:
|
||||||
|
"""Return a dict of fields which can be exported against this serializer instance.
|
||||||
|
|
||||||
|
Note: Any fields which should be excluded from export have already been removed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: A dictionary of field names and field objects
|
||||||
|
"""
|
||||||
|
fields = {}
|
||||||
|
|
||||||
|
if meta := getattr(self, 'Meta', None):
|
||||||
|
write_only_fields = getattr(meta, 'write_only_fields', [])
|
||||||
|
else:
|
||||||
|
write_only_fields = []
|
||||||
|
|
||||||
|
for name, field in self.fields.items():
|
||||||
|
# Skip write-only fields
|
||||||
|
if getattr(field, 'write_only', False) or name in write_only_fields:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip tags fields
|
||||||
|
# TODO: Implement tag field export support
|
||||||
|
if issubclass(field.__class__, TagListSerializerField):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Top-level serializer fields can be exported with dot notation
|
||||||
|
if issubclass(field.__class__, serializers.Serializer):
|
||||||
|
fields.update(self.get_child_fields(name, field))
|
||||||
|
continue
|
||||||
|
|
||||||
|
fields[name] = field
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def get_child_fields(self, field_name: str, field) -> dict:
|
||||||
|
"""Return a dictionary of child fields for a given field.
|
||||||
|
|
||||||
|
Only child fields which match the 'export_child_fields' list will be returned.
|
||||||
|
"""
|
||||||
|
child_fields = {}
|
||||||
|
|
||||||
|
if sub_fields := getattr(field, 'fields', None):
|
||||||
|
for sub_name, sub_field in sub_fields.items():
|
||||||
|
name = f'{field_name}.{sub_name}'
|
||||||
|
|
||||||
|
if name in self.export_child_fields:
|
||||||
|
sub_field.parent_field = field
|
||||||
|
child_fields[name] = sub_field
|
||||||
|
|
||||||
|
return child_fields
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def arrange_export_headers(cls, headers: list) -> list:
|
||||||
|
"""Optional method to arrange the export headers.
|
||||||
|
|
||||||
|
By default, the headers are returned in the order they are provided.
|
||||||
|
"""
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def get_nested_value(self, row: dict, key: str) -> any:
|
||||||
|
"""Get a nested value from a dictionary.
|
||||||
|
|
||||||
|
This method allows for dot notation to access nested fields.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
row: The dictionary to extract the value from
|
||||||
|
key: The key to extract
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
any: The extracted value
|
||||||
|
"""
|
||||||
|
keys = key.split('.')
|
||||||
|
|
||||||
|
value = row
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
if not value:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = value.get(key, None)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def generate_headers(self) -> OrderedDict:
|
||||||
|
"""Generate a list of default headers for the exported data.
|
||||||
|
|
||||||
|
Returns an ordered dict of field names and their corresponding human-readable labels.
|
||||||
|
|
||||||
|
e.g.
|
||||||
|
|
||||||
|
{
|
||||||
|
'id': 'ID',
|
||||||
|
'name': 'Name',
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
fields = self.get_exportable_fields()
|
||||||
|
field_names = self.arrange_export_headers(list(fields.keys()))
|
||||||
|
|
||||||
|
headers = OrderedDict()
|
||||||
|
|
||||||
|
for field_name in field_names:
|
||||||
|
field = fields[field_name]
|
||||||
|
|
||||||
|
label = getattr(field, 'label', field_name)
|
||||||
|
|
||||||
|
if parent := getattr(field, 'parent_field', None):
|
||||||
|
label = f'{parent.label}.{label}'
|
||||||
|
|
||||||
|
headers[field_name] = label
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def export_to_file(self, data, headers: OrderedDict, file_format):
|
||||||
|
"""Export the queryset to a file in the specified format.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
queryset: The queryset to export
|
||||||
|
data: The serialized dataset to export
|
||||||
|
headers: The headers to use for the exported data {field: label}
|
||||||
|
file_format: The file format to export to
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File object containing the exported data
|
||||||
|
"""
|
||||||
|
field_names = list(headers.keys())
|
||||||
|
field_headers = list(headers.values())
|
||||||
|
|
||||||
|
# Create a new dataset with the provided header labels
|
||||||
|
dataset = tablib.Dataset(headers=field_headers)
|
||||||
|
|
||||||
|
for row in data:
|
||||||
|
dataset.append([self.get_nested_value(row, f) for f in field_names])
|
||||||
|
|
||||||
|
return dataset.export(file_format)
|
||||||
|
|
||||||
|
|
||||||
|
class DataExportViewMixin:
|
||||||
|
"""An API view mixin for directly exporting selected data.
|
||||||
|
|
||||||
|
To perform a data export against an API endpoint which inherits from this mixin,
|
||||||
|
perform a GET request with 'export=True'.
|
||||||
|
|
||||||
|
This will run validation against the DataExportOptionsSerializer.
|
||||||
|
|
||||||
|
Once the export options have been validated, a new DataOutput object will be created,
|
||||||
|
and this will be returned to the client (including a download link to the exported file).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def is_exporting(self) -> bool:
|
||||||
|
"""Determine if the view is currently exporting data."""
|
||||||
|
if request := getattr(self, 'request', None):
|
||||||
|
return str2bool(
|
||||||
|
request.data.get('export') or request.query_params.get('export')
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_plugin(self, plugin_slug=None):
|
||||||
|
"""Return the plugin instance associated with the export request.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
plugin_slug: The slug of the plugin to use for exporting the data (optional)
|
||||||
|
"""
|
||||||
|
PLUGIN_KEY = 'export_plugin'
|
||||||
|
|
||||||
|
if not plugin_slug:
|
||||||
|
if request := getattr(self, 'request', None):
|
||||||
|
plugin_slug = request.data.get(PLUGIN_KEY) or request.query_params.get(
|
||||||
|
PLUGIN_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
if plugin_slug:
|
||||||
|
return registry.get_plugin(
|
||||||
|
plugin_slug, active=True, with_mixin=PluginMixinEnum.EXPORTER
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
"""Return the serializer instance for the view.
|
||||||
|
|
||||||
|
- Only applies for OPTIONS or GET requests
|
||||||
|
- OPTIONS requests to determine plugin serializer options
|
||||||
|
- GET request to perform the data export
|
||||||
|
- If the view is exporting data, return the DataExportOptionsSerializer.
|
||||||
|
- Otherwise, return the default serializer.
|
||||||
|
"""
|
||||||
|
exporting = kwargs.pop('exporting', None)
|
||||||
|
|
||||||
|
if exporting is None:
|
||||||
|
exporting = (
|
||||||
|
self.request.method.lower() in ['options', 'get']
|
||||||
|
and self.is_exporting()
|
||||||
|
)
|
||||||
|
|
||||||
|
if exporting:
|
||||||
|
# Override kwargs when initializing the DataExportOptionsSerializer
|
||||||
|
export_kwargs = {
|
||||||
|
'plugin': self.get_plugin(),
|
||||||
|
'request': self.request,
|
||||||
|
'data': kwargs.get('data'),
|
||||||
|
'context': kwargs.get('context'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the base model associated with this view
|
||||||
|
try:
|
||||||
|
serializer_class = self.get_serializer_class()
|
||||||
|
export_kwargs['model_class'] = serializer_class.Meta.model
|
||||||
|
except AttributeError:
|
||||||
|
export_kwargs['model_class'] = None
|
||||||
|
|
||||||
|
return data_exporter.serializers.DataExportOptionsSerializer(
|
||||||
|
*args, **export_kwargs
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
|
def export_data(
|
||||||
|
self,
|
||||||
|
export_plugin,
|
||||||
|
export_format: str,
|
||||||
|
export_context: dict,
|
||||||
|
output: DataOutput,
|
||||||
|
):
|
||||||
|
"""Export the data in the specified format.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
export_plugin: The plugin instance to use for exporting the data
|
||||||
|
export_format: The file format to export the data in
|
||||||
|
export_context: Additional context data to pass to the plugin
|
||||||
|
output: The DataOutput object to write to
|
||||||
|
|
||||||
|
- By default, uses the provided serializer to generate the data, and return it as a file download.
|
||||||
|
- If a plugin is specified, the plugin can be used to augment or replace the export functionality.
|
||||||
|
"""
|
||||||
|
# Get the base serializer class for the view
|
||||||
|
serializer_class = self.get_serializer_class()
|
||||||
|
|
||||||
|
if not issubclass(serializer_class, DataExportSerializerMixin):
|
||||||
|
raise ValidationError(
|
||||||
|
'Serializer class must inherit from DataExportSerializerMixin'
|
||||||
|
)
|
||||||
|
|
||||||
|
export_error = _('Error occurred during data export')
|
||||||
|
|
||||||
|
context = self.get_serializer_context()
|
||||||
|
|
||||||
|
# Perform initial filtering of the queryset, based on the query parameters
|
||||||
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
|
|
||||||
|
# Perform additional filtering, as per the provided plugin
|
||||||
|
try:
|
||||||
|
queryset = export_plugin.filter_queryset(queryset)
|
||||||
|
except Exception:
|
||||||
|
InvenTree.exceptions.log_error(
|
||||||
|
f'plugins.{export_plugin.slug}.filter_queryset'
|
||||||
|
)
|
||||||
|
raise ValidationError(export_error)
|
||||||
|
|
||||||
|
# Update the output instance with the total number of items to export
|
||||||
|
output.total = queryset.count()
|
||||||
|
output.save()
|
||||||
|
|
||||||
|
data = None
|
||||||
|
serializer = serializer_class(context=context, exporting=True)
|
||||||
|
serializer.initial_data = queryset
|
||||||
|
|
||||||
|
# Construct 'default' headers (note: may be overridden by plugin)
|
||||||
|
headers = serializer.generate_headers()
|
||||||
|
|
||||||
|
# Generate a filename for the exported data (implemented by the plugin)
|
||||||
|
try:
|
||||||
|
filename = export_plugin.generate_filename(
|
||||||
|
serializer_class.Meta.model, export_format
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
InvenTree.exceptions.log_error(
|
||||||
|
f'plugins.{export_plugin.slug}.generate_filename'
|
||||||
|
)
|
||||||
|
raise ValidationError(export_error)
|
||||||
|
|
||||||
|
# The provided plugin is responsible for exporting the data
|
||||||
|
# The returned data *must* be a list of dict objects
|
||||||
|
try:
|
||||||
|
data = export_plugin.export_data(
|
||||||
|
queryset, serializer_class, headers, export_context, output
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
InvenTree.exceptions.log_error(f'plugins.{export_plugin.slug}.export_data')
|
||||||
|
raise ValidationError(export_error)
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValidationError(
|
||||||
|
_('Data export plugin returned incorrect data format')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Augment / update the headers (if required)
|
||||||
|
if hasattr(export_plugin, 'update_headers'):
|
||||||
|
try:
|
||||||
|
headers = export_plugin.update_headers(headers, export_context)
|
||||||
|
except Exception:
|
||||||
|
InvenTree.exceptions.log_error(
|
||||||
|
f'plugins.{export_plugin.slug}.update_headers'
|
||||||
|
)
|
||||||
|
raise ValidationError(export_error)
|
||||||
|
|
||||||
|
# Now, export the data to file
|
||||||
|
try:
|
||||||
|
datafile = serializer.export_to_file(data, headers, export_format)
|
||||||
|
except Exception:
|
||||||
|
InvenTree.exceptions.log_error('export_to_file')
|
||||||
|
raise ValidationError(_('Error occurred during data export'))
|
||||||
|
|
||||||
|
# Update the output object with the exported data
|
||||||
|
output.progress = 100
|
||||||
|
output.complete = True
|
||||||
|
output.output = ContentFile(datafile, filename)
|
||||||
|
output.save()
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Override the GET method to determine export options."""
|
||||||
|
from common.serializers import DataOutputSerializer
|
||||||
|
|
||||||
|
# If we are not exporting data, return the default response
|
||||||
|
if self.is_exporting():
|
||||||
|
# Determine if the export options are valid
|
||||||
|
|
||||||
|
# Extract the export options from the provided query parameters
|
||||||
|
export_options = {}
|
||||||
|
|
||||||
|
for key in request.query_params:
|
||||||
|
if key.startswith('export_'):
|
||||||
|
export_options[key] = request.query_params.get(key)
|
||||||
|
|
||||||
|
# Construct the options serializer with the provided data
|
||||||
|
serializer = self.get_serializer(exporting=True, data=export_options)
|
||||||
|
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer_data = serializer.validated_data
|
||||||
|
|
||||||
|
export_format = serializer_data.pop('export_format', 'csv')
|
||||||
|
plugin_slug = serializer_data.pop('export_plugin', 'inventree-exporter')
|
||||||
|
export_plugin = self.get_plugin(plugin_slug)
|
||||||
|
|
||||||
|
export_context = {}
|
||||||
|
|
||||||
|
# Also run the data against the plugin serializer
|
||||||
|
if export_plugin:
|
||||||
|
if hasattr(export_plugin, 'get_export_options_serializer'):
|
||||||
|
if plugin_serializer := export_plugin.get_export_options_serializer(
|
||||||
|
data=export_options
|
||||||
|
):
|
||||||
|
plugin_serializer.is_valid(raise_exception=True)
|
||||||
|
export_context = plugin_serializer.validated_data
|
||||||
|
|
||||||
|
user = getattr(request, 'user', None)
|
||||||
|
|
||||||
|
# Add in extra context data for the plugin
|
||||||
|
export_context['user'] = user
|
||||||
|
|
||||||
|
# Create an output object to export against
|
||||||
|
output = DataOutput.objects.create(
|
||||||
|
user=user if user and user.is_authenticated else None,
|
||||||
|
total=0, # Note: this should get updated by the export task
|
||||||
|
progress=0,
|
||||||
|
complete=False,
|
||||||
|
output_type=DataOutput.DataOutputTypes.EXPORT,
|
||||||
|
plugin=export_plugin.slug,
|
||||||
|
output=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Offload the export task to a background worker
|
||||||
|
# This is to avoid blocking the web server
|
||||||
|
# Note: The export task will loop back and call the 'export_data' method on this class
|
||||||
|
offload_task(
|
||||||
|
data_exporter.tasks.export_data,
|
||||||
|
self.__class__,
|
||||||
|
request.user.id,
|
||||||
|
request.query_params,
|
||||||
|
plugin_slug,
|
||||||
|
export_format,
|
||||||
|
export_context,
|
||||||
|
output.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
output.refresh_from_db()
|
||||||
|
|
||||||
|
# Return a response to the frontend
|
||||||
|
return Response(DataOutputSerializer(output).data, status=200)
|
||||||
|
|
||||||
|
return super().get(request, *args, **kwargs)
|
69
src/backend/InvenTree/data_exporter/serializers.py
Normal file
69
src/backend/InvenTree/data_exporter/serializers.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""Serializers for the exporter app."""
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
import InvenTree.helpers
|
||||||
|
import InvenTree.serializers
|
||||||
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
|
|
||||||
|
class DataExportOptionsSerializer(serializers.Serializer):
|
||||||
|
"""Serializer class for defining a data export session."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Metaclass options for this serializer."""
|
||||||
|
|
||||||
|
fields = ['export_format', 'export_plugin']
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Construct the DataExportOptionsSerializer.
|
||||||
|
|
||||||
|
- The exact nature of the available fields depends on which plugin is selected.
|
||||||
|
- The selected plugin may 'extend' the fields available in the serializer.
|
||||||
|
"""
|
||||||
|
# Reset fields to a known state
|
||||||
|
self.Meta.fields = ['export_format', 'export_plugin']
|
||||||
|
|
||||||
|
# Generate a list of plugins to choose from
|
||||||
|
# If a model type is provided, use this to filter the list of plugins
|
||||||
|
model_class = kwargs.pop('model_class', None)
|
||||||
|
request = kwargs.pop('request', None)
|
||||||
|
|
||||||
|
# Is a plugin serializer provided?
|
||||||
|
if plugin := kwargs.pop('plugin', None):
|
||||||
|
if hasattr(plugin, 'get_export_options_serializer'):
|
||||||
|
plugin_serializer = plugin.get_export_options_serializer()
|
||||||
|
|
||||||
|
if plugin_serializer:
|
||||||
|
for key, field in plugin_serializer.fields.items():
|
||||||
|
# Note: Custom fields *must* start with 'export_' prefix
|
||||||
|
if key.startswith('export_') and key not in self.Meta.fields:
|
||||||
|
self.Meta.fields.append(key)
|
||||||
|
setattr(self, key, field)
|
||||||
|
|
||||||
|
plugin_options = []
|
||||||
|
|
||||||
|
for plugin in registry.with_mixin(PluginMixinEnum.EXPORTER):
|
||||||
|
if plugin.supports_export(model_class, request.user):
|
||||||
|
plugin_options.append((plugin.slug, plugin.name))
|
||||||
|
|
||||||
|
self.fields['export_plugin'].choices = plugin_options
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
export_format = serializers.ChoiceField(
|
||||||
|
choices=InvenTree.helpers.GetExportOptions(),
|
||||||
|
default='csv',
|
||||||
|
label=_('Export Format'),
|
||||||
|
help_text=_('Select export file format'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Select plugin for export - the options will be dynamically generated later on
|
||||||
|
export_plugin = serializers.ChoiceField(
|
||||||
|
choices=[],
|
||||||
|
default='inventree-exporter',
|
||||||
|
label=_('Export Plugin'),
|
||||||
|
help_text=_('Select export plugin'),
|
||||||
|
)
|
62
src/backend/InvenTree/data_exporter/tasks.py
Normal file
62
src/backend/InvenTree/data_exporter/tasks.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""Background tasks for the exporting app."""
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test.client import RequestFactory
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from common.models import DataOutput
|
||||||
|
|
||||||
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
|
||||||
|
def export_data(
|
||||||
|
view_class,
|
||||||
|
user_id: int,
|
||||||
|
query_params: dict,
|
||||||
|
plugin_key: str,
|
||||||
|
export_format: str,
|
||||||
|
export_context: dict,
|
||||||
|
output_id: int,
|
||||||
|
):
|
||||||
|
"""Perform the data export task using the provided parameters.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
view_class: The class of the view to export data from
|
||||||
|
user_id: The ID of the user who requested the export
|
||||||
|
query_params: Query parameters for the export
|
||||||
|
plugin_key: The key for the export plugin
|
||||||
|
export_format: The output format for the export
|
||||||
|
export_context: Additional options for the export
|
||||||
|
output_id: The ID of the DataOutput instance to write to
|
||||||
|
|
||||||
|
This function is designed to be called by the background task,
|
||||||
|
to avoid blocking the web server.
|
||||||
|
"""
|
||||||
|
from plugin import registry
|
||||||
|
|
||||||
|
if (plugin := registry.get_plugin(plugin_key)) is None:
|
||||||
|
logger.warning("export_data: Plugin '%s' not found", plugin_key)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (user := User.objects.filter(pk=user_id).first()) is None:
|
||||||
|
logger.warning('export_data: User not found: %d', user_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (output := DataOutput.objects.filter(pk=output_id).first()) is None:
|
||||||
|
logger.warning('export_data: Output object not found: %d', output_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Recreate the request object - this is required for the view to function correctly
|
||||||
|
# Note that the request object cannot be pickled, so we need to recreate it here
|
||||||
|
request = RequestFactory()
|
||||||
|
request.user = user
|
||||||
|
request.query_params = query_params
|
||||||
|
|
||||||
|
view = view_class()
|
||||||
|
view.request = request
|
||||||
|
view.args = getattr(view, 'args', ())
|
||||||
|
view.kwargs = getattr(view, 'kwargs', {})
|
||||||
|
view.format_kwarg = getattr(view, 'format_kwarg', None)
|
||||||
|
|
||||||
|
view.export_data(plugin, export_format, export_context, output)
|
@ -1,4 +1,4 @@
|
|||||||
"""Generic event enumerations for InevnTree."""
|
"""Generic event enumerations for InvenTree."""
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ from rest_framework.response import Response
|
|||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
import common.serializers
|
import common.serializers
|
||||||
from importer.mixins import DataExportViewMixin
|
from data_exporter.mixins import DataExportViewMixin
|
||||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||||
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||||
from InvenTree.permissions import IsStaffOrReadOnly
|
from InvenTree.permissions import IsStaffOrReadOnly
|
||||||
|
@ -1,14 +1,8 @@
|
|||||||
"""Mixin classes for data import/export functionality."""
|
"""Mixin classes for data import/export functionality."""
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
import tablib
|
|
||||||
from rest_framework import fields, serializers
|
from rest_framework import fields, serializers
|
||||||
from taggit.serializers import TagListSerializerField
|
from taggit.serializers import TagListSerializerField
|
||||||
|
|
||||||
from InvenTree.helpers import DownloadFile, GetExportFormats, current_date
|
|
||||||
|
|
||||||
|
|
||||||
class DataImportSerializerMixin:
|
class DataImportSerializerMixin:
|
||||||
"""Mixin class for adding data import functionality to a DRF serializer."""
|
"""Mixin class for adding data import functionality to a DRF serializer."""
|
||||||
@ -89,242 +83,3 @@ class DataImportSerializerMixin:
|
|||||||
importable_fields[name] = field
|
importable_fields[name] = field
|
||||||
|
|
||||||
return importable_fields
|
return importable_fields
|
||||||
|
|
||||||
|
|
||||||
class DataExportSerializerMixin:
|
|
||||||
"""Mixin class for adding data export functionality to a DRF serializer.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
export_only_fields: List of field names which are only used during data export
|
|
||||||
export_exclude_fields: List of field names which are excluded during data export
|
|
||||||
export_child_fields: List of child fields which are exported (using dot notation)
|
|
||||||
"""
|
|
||||||
|
|
||||||
export_only_fields = []
|
|
||||||
export_exclude_fields = []
|
|
||||||
export_child_fields = []
|
|
||||||
|
|
||||||
def get_export_only_fields(self, **kwargs) -> list:
|
|
||||||
"""Return the list of field names which are only used during data export."""
|
|
||||||
return self.export_only_fields
|
|
||||||
|
|
||||||
def get_export_exclude_fields(self, **kwargs) -> list:
|
|
||||||
"""Return the list of field names which are excluded during data export."""
|
|
||||||
return self.export_exclude_fields
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
"""Initialise the DataExportSerializerMixin.
|
|
||||||
|
|
||||||
Determine if the serializer is being used for data export,
|
|
||||||
and if so, adjust the serializer fields accordingly.
|
|
||||||
"""
|
|
||||||
exporting = kwargs.pop('exporting', False)
|
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
if exporting:
|
|
||||||
# Exclude fields which are not required for data export
|
|
||||||
for field in self.get_export_exclude_fields(**kwargs):
|
|
||||||
self.fields.pop(field, None)
|
|
||||||
else:
|
|
||||||
# Exclude fields which are only used for data export
|
|
||||||
for field in self.get_export_only_fields(**kwargs):
|
|
||||||
self.fields.pop(field, None)
|
|
||||||
|
|
||||||
def get_exportable_fields(self) -> dict:
|
|
||||||
"""Return a dict of fields which can be exported against this serializer instance.
|
|
||||||
|
|
||||||
Note: Any fields which should be excluded from export have already been removed
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: A dictionary of field names and field objects
|
|
||||||
"""
|
|
||||||
fields = {}
|
|
||||||
|
|
||||||
if meta := getattr(self, 'Meta', None):
|
|
||||||
write_only_fields = getattr(meta, 'write_only_fields', [])
|
|
||||||
else:
|
|
||||||
write_only_fields = []
|
|
||||||
|
|
||||||
for name, field in self.fields.items():
|
|
||||||
# Skip write-only fields
|
|
||||||
if getattr(field, 'write_only', False) or name in write_only_fields:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip tags fields
|
|
||||||
# TODO: Implement tag field export support
|
|
||||||
if issubclass(field.__class__, TagListSerializerField):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Top-level serializer fields can be exported with dot notation
|
|
||||||
# Skip fields which are themselves serializers
|
|
||||||
if issubclass(field.__class__, serializers.Serializer):
|
|
||||||
fields.update(self.get_child_fields(name, field))
|
|
||||||
continue
|
|
||||||
|
|
||||||
fields[name] = field
|
|
||||||
|
|
||||||
return fields
|
|
||||||
|
|
||||||
def get_child_fields(self, field_name: str, field) -> dict:
|
|
||||||
"""Return a dictionary of child fields for a given field.
|
|
||||||
|
|
||||||
Only child fields which match the 'export_child_fields' list will be returned.
|
|
||||||
"""
|
|
||||||
child_fields = {}
|
|
||||||
|
|
||||||
if sub_fields := getattr(field, 'fields', None):
|
|
||||||
for sub_name, sub_field in sub_fields.items():
|
|
||||||
name = f'{field_name}.{sub_name}'
|
|
||||||
|
|
||||||
if name in self.export_child_fields:
|
|
||||||
sub_field.parent_field = field
|
|
||||||
child_fields[name] = sub_field
|
|
||||||
|
|
||||||
return child_fields
|
|
||||||
|
|
||||||
def get_exported_filename(self, export_format) -> str:
|
|
||||||
"""Return the filename for the exported data file.
|
|
||||||
|
|
||||||
An implementing class can override this implementation if required.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
export_format: The file format to be exported
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The filename for the exported file
|
|
||||||
"""
|
|
||||||
model = self.Meta.model
|
|
||||||
date = current_date().isoformat()
|
|
||||||
|
|
||||||
return f'InvenTree_{model.__name__}_{date}.{export_format}'
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def arrange_export_headers(cls, headers: list) -> list:
|
|
||||||
"""Optional method to arrange the export headers."""
|
|
||||||
return headers
|
|
||||||
|
|
||||||
def get_nested_value(self, row: dict, key: str) -> any:
|
|
||||||
"""Get a nested value from a dictionary.
|
|
||||||
|
|
||||||
This method allows for dot notation to access nested fields.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
row: The dictionary to extract the value from
|
|
||||||
key: The key to extract
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
any: The extracted value
|
|
||||||
"""
|
|
||||||
keys = key.split('.')
|
|
||||||
|
|
||||||
value = row
|
|
||||||
|
|
||||||
for key in keys:
|
|
||||||
if not value:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
|
|
||||||
value = value.get(key, None)
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
def process_row(self, row):
|
|
||||||
"""Optional method to process a row before exporting it."""
|
|
||||||
return row
|
|
||||||
|
|
||||||
def export_to_file(self, data, file_format):
|
|
||||||
"""Export the queryset to a file in the specified format.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
queryset: The queryset to export
|
|
||||||
data: The serialized dataset to export
|
|
||||||
file_format: The file format to export to
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
File object containing the exported data
|
|
||||||
"""
|
|
||||||
# Extract all exportable fields from this serializer
|
|
||||||
fields = self.get_exportable_fields()
|
|
||||||
|
|
||||||
field_names = self.arrange_export_headers(list(fields.keys()))
|
|
||||||
|
|
||||||
# Extract human-readable field names
|
|
||||||
headers = []
|
|
||||||
|
|
||||||
for field_name, field in fields.items():
|
|
||||||
field = fields[field_name]
|
|
||||||
|
|
||||||
label = getattr(field, 'label', field_name)
|
|
||||||
|
|
||||||
if parent := getattr(field, 'parent_field', None):
|
|
||||||
label = f'{parent.label}.{label}'
|
|
||||||
|
|
||||||
headers.append(label)
|
|
||||||
|
|
||||||
dataset = tablib.Dataset(headers=headers)
|
|
||||||
|
|
||||||
for row in data:
|
|
||||||
row = self.process_row(row)
|
|
||||||
dataset.append([self.get_nested_value(row, f) for f in field_names])
|
|
||||||
|
|
||||||
return dataset.export(file_format)
|
|
||||||
|
|
||||||
|
|
||||||
class DataImportExportSerializerMixin(
|
|
||||||
DataImportSerializerMixin, DataExportSerializerMixin
|
|
||||||
):
|
|
||||||
"""Mixin class for adding data import/export functionality to a DRF serializer."""
|
|
||||||
|
|
||||||
|
|
||||||
class DataExportViewMixin:
|
|
||||||
"""Mixin class for exporting a dataset via the API.
|
|
||||||
|
|
||||||
Adding this mixin to an API view allows the user to export the dataset to file in a variety of formats.
|
|
||||||
|
|
||||||
We achieve this by overriding the 'get' method, and checking for the presence of the required query parameter.
|
|
||||||
"""
|
|
||||||
|
|
||||||
EXPORT_QUERY_PARAMETER = 'export'
|
|
||||||
|
|
||||||
def export_data(self, export_format):
|
|
||||||
"""Export the data in the specified format.
|
|
||||||
|
|
||||||
Use the provided serializer to generate the data, and return it as a file download.
|
|
||||||
"""
|
|
||||||
serializer_class = self.get_serializer_class()
|
|
||||||
|
|
||||||
if not issubclass(serializer_class, DataExportSerializerMixin):
|
|
||||||
raise TypeError(
|
|
||||||
'Serializer class must inherit from DataExportSerialierMixin'
|
|
||||||
)
|
|
||||||
|
|
||||||
queryset = self.filter_queryset(self.get_queryset())
|
|
||||||
|
|
||||||
serializer = serializer_class(exporting=True)
|
|
||||||
serializer.initial_data = queryset
|
|
||||||
|
|
||||||
# Export dataset with a second copy of the serializer
|
|
||||||
# This is because when we pass many=True, the returned class is a ListSerializer
|
|
||||||
data = serializer_class(queryset, many=True, exporting=True).data
|
|
||||||
|
|
||||||
filename = serializer.get_exported_filename(export_format)
|
|
||||||
datafile = serializer.export_to_file(data, export_format)
|
|
||||||
|
|
||||||
return DownloadFile(datafile, filename=filename)
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
"""Override the 'get' method to check for the export query parameter."""
|
|
||||||
if export_format := request.query_params.get(self.EXPORT_QUERY_PARAMETER, None):
|
|
||||||
export_format = str(export_format).strip().lower()
|
|
||||||
if export_format in GetExportFormats():
|
|
||||||
return self.export_data(export_format)
|
|
||||||
else:
|
|
||||||
raise ValidationError({
|
|
||||||
self.EXPORT_QUERY_PARAMETER: _('Invalid export format')
|
|
||||||
})
|
|
||||||
|
|
||||||
# If the export query parameter is not present, return the default response
|
|
||||||
return super().get(request, *args, **kwargs)
|
|
||||||
|
@ -19,8 +19,8 @@ from rest_framework.response import Response
|
|||||||
import common.models
|
import common.models
|
||||||
import common.settings
|
import common.settings
|
||||||
import company.models
|
import company.models
|
||||||
|
from data_exporter.mixins import DataExportViewMixin
|
||||||
from generic.states.api import StatusView
|
from generic.states.api import StatusView
|
||||||
from importer.mixins import DataExportViewMixin
|
|
||||||
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
|
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
|
||||||
from InvenTree.filters import (
|
from InvenTree.filters import (
|
||||||
SEARCH_ORDER_FILTER,
|
SEARCH_ORDER_FILTER,
|
||||||
@ -57,7 +57,7 @@ class GeneralExtraLineList(DataExportViewMixin):
|
|||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
"""Return the annotated queryset for this endpoint."""
|
"""Return the annotated queryset for this endpoint."""
|
||||||
@ -336,7 +336,7 @@ class PurchaseOrderMixin:
|
|||||||
# Ensure the request context is passed through
|
# Ensure the request context is passed through
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
"""Return the annotated queryset for this endpoint."""
|
"""Return the annotated queryset for this endpoint."""
|
||||||
@ -594,7 +594,7 @@ class PurchaseOrderLineItemMixin:
|
|||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
"""Override the perform_update method to auto-update pricing if required."""
|
"""Override the perform_update method to auto-update pricing if required."""
|
||||||
@ -786,7 +786,7 @@ class SalesOrderMixin:
|
|||||||
# Ensure the context is passed through to the serializer
|
# Ensure the context is passed through to the serializer
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
"""Return annotated queryset for this endpoint."""
|
"""Return annotated queryset for this endpoint."""
|
||||||
@ -942,7 +942,7 @@ class SalesOrderLineItemMixin:
|
|||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
"""Return annotated queryset for this endpoint."""
|
"""Return annotated queryset for this endpoint."""
|
||||||
@ -1221,7 +1221,7 @@ class SalesOrderAllocationList(SalesOrderAllocationMixin, BulkUpdateMixin, ListA
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderAllocationDetail(SalesOrderAllocationMixin, RetrieveUpdateDestroyAPI):
|
class SalesOrderAllocationDetail(SalesOrderAllocationMixin, RetrieveUpdateDestroyAPI):
|
||||||
@ -1385,7 +1385,7 @@ class ReturnOrderMixin:
|
|||||||
# Ensure the context is passed through to the serializer
|
# Ensure the context is passed through to the serializer
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
"""Return annotated queryset for this endpoint."""
|
"""Return annotated queryset for this endpoint."""
|
||||||
@ -1536,7 +1536,7 @@ class ReturnOrderLineItemMixin:
|
|||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
"""Return annotated queryset for this endpoint."""
|
"""Return annotated queryset for this endpoint."""
|
||||||
|
@ -35,7 +35,6 @@ from company.serializers import (
|
|||||||
SupplierPartSerializer,
|
SupplierPartSerializer,
|
||||||
)
|
)
|
||||||
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
||||||
from importer.mixins import DataImportExportSerializerMixin
|
|
||||||
from importer.registry import register_importer
|
from importer.registry import register_importer
|
||||||
from InvenTree.helpers import (
|
from InvenTree.helpers import (
|
||||||
current_date,
|
current_date,
|
||||||
@ -44,6 +43,7 @@ from InvenTree.helpers import (
|
|||||||
normalize,
|
normalize,
|
||||||
str2bool,
|
str2bool,
|
||||||
)
|
)
|
||||||
|
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||||
from InvenTree.ready import isGeneratingSchema
|
from InvenTree.ready import isGeneratingSchema
|
||||||
from InvenTree.serializers import (
|
from InvenTree.serializers import (
|
||||||
InvenTreeCurrencySerializer,
|
InvenTreeCurrencySerializer,
|
||||||
|
@ -870,14 +870,13 @@ class PurchaseOrderDownloadTest(OrderTest):
|
|||||||
"""Incorrect format should default raise an error."""
|
"""Incorrect format should default raise an error."""
|
||||||
url = reverse('api-po-list')
|
url = reverse('api-po-list')
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
response = self.export_data(url, export_format='xyz', expected_code=400)
|
||||||
self.download_file(url, {'export': 'xyz'})
|
self.assertIn('is not a valid choice', str(response['export_format']))
|
||||||
|
|
||||||
def test_download_csv(self):
|
def test_download_csv(self):
|
||||||
"""Download PurchaseOrder data as .csv."""
|
"""Download PurchaseOrder data as .csv."""
|
||||||
with self.download_file(
|
with self.export_data(
|
||||||
reverse('api-po-list'),
|
reverse('api-po-list'),
|
||||||
{'export': 'csv'},
|
|
||||||
expected_code=200,
|
expected_code=200,
|
||||||
expected_fn=r'InvenTree_PurchaseOrder_.+\.csv',
|
expected_fn=r'InvenTree_PurchaseOrder_.+\.csv',
|
||||||
) as file:
|
) as file:
|
||||||
@ -896,12 +895,12 @@ class PurchaseOrderDownloadTest(OrderTest):
|
|||||||
|
|
||||||
def test_download_line_items(self):
|
def test_download_line_items(self):
|
||||||
"""Test that the PurchaseOrderLineItems can be downloaded to a file."""
|
"""Test that the PurchaseOrderLineItems can be downloaded to a file."""
|
||||||
with self.download_file(
|
with self.export_data(
|
||||||
reverse('api-po-line-list'),
|
reverse('api-po-line-list'),
|
||||||
{'export': 'xlsx'},
|
export_format='xlsx',
|
||||||
decode=False,
|
|
||||||
expected_code=200,
|
expected_code=200,
|
||||||
expected_fn=r'InvenTree_PurchaseOrderLineItem.+\.xlsx',
|
expected_fn=r'InvenTree_PurchaseOrderLineItem.+\.xlsx',
|
||||||
|
decode=False,
|
||||||
) as file:
|
) as file:
|
||||||
self.assertIsInstance(file, io.BytesIO)
|
self.assertIsInstance(file, io.BytesIO)
|
||||||
|
|
||||||
@ -1602,9 +1601,9 @@ class SalesOrderTest(OrderTest):
|
|||||||
|
|
||||||
# Download file, check we get a 200 response
|
# Download file, check we get a 200 response
|
||||||
for fmt in ['csv', 'xlsx', 'tsv']:
|
for fmt in ['csv', 'xlsx', 'tsv']:
|
||||||
self.download_file(
|
self.export_data(
|
||||||
reverse('api-so-list'),
|
reverse('api-so-list'),
|
||||||
{'export': fmt},
|
export_format=fmt,
|
||||||
decode=fmt == 'csv',
|
decode=fmt == 'csv',
|
||||||
expected_code=200,
|
expected_code=200,
|
||||||
expected_fn=r'InvenTree_SalesOrder_.+',
|
expected_fn=r'InvenTree_SalesOrder_.+',
|
||||||
@ -1856,16 +1855,16 @@ class SalesOrderDownloadTest(OrderTest):
|
|||||||
"""Test that downloading without the 'export' option fails."""
|
"""Test that downloading without the 'export' option fails."""
|
||||||
url = reverse('api-so-list')
|
url = reverse('api-so-list')
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
response = self.export_data(url, export_plugin='no-plugin', expected_code=400)
|
||||||
self.download_file(url, {}, expected_code=200)
|
self.assertIn('is not a valid choice', str(response['export_plugin']))
|
||||||
|
|
||||||
def test_download_xlsx(self):
|
def test_download_xlsx(self):
|
||||||
"""Test xlsx file download."""
|
"""Test xlsx file download."""
|
||||||
url = reverse('api-so-list')
|
url = reverse('api-so-list')
|
||||||
|
|
||||||
# Download .xls file
|
# Download .xls file
|
||||||
with self.download_file(
|
with self.export_data(
|
||||||
url, {'export': 'xlsx'}, expected_code=200, decode=False
|
url, export_format='xlsx', expected_code=200, decode=False
|
||||||
) as file:
|
) as file:
|
||||||
self.assertIsInstance(file, io.BytesIO)
|
self.assertIsInstance(file, io.BytesIO)
|
||||||
|
|
||||||
@ -1888,9 +1887,7 @@ class SalesOrderDownloadTest(OrderTest):
|
|||||||
excluded_cols = ['metadata']
|
excluded_cols = ['metadata']
|
||||||
|
|
||||||
# Download .xls file
|
# Download .xls file
|
||||||
with self.download_file(
|
with self.export_data(url, export_format='csv') as file:
|
||||||
url, {'export': 'csv'}, expected_code=200, decode=True
|
|
||||||
) as file:
|
|
||||||
data = self.process_csv(
|
data = self.process_csv(
|
||||||
file,
|
file,
|
||||||
required_cols=required_cols,
|
required_cols=required_cols,
|
||||||
@ -1905,9 +1902,7 @@ class SalesOrderDownloadTest(OrderTest):
|
|||||||
self.assertEqual(line['Order Status'], str(order.status))
|
self.assertEqual(line['Order Status'], str(order.status))
|
||||||
|
|
||||||
# Download only outstanding sales orders
|
# Download only outstanding sales orders
|
||||||
with self.download_file(
|
with self.export_data(url, {'outstanding': True}, export_format='tsv') as file:
|
||||||
url, {'export': 'tsv', 'outstanding': True}, expected_code=200, decode=True
|
|
||||||
) as file:
|
|
||||||
self.process_csv(
|
self.process_csv(
|
||||||
file,
|
file,
|
||||||
required_cols=required_cols,
|
required_cols=required_cols,
|
||||||
|
@ -19,7 +19,7 @@ import order.models
|
|||||||
import part.filters
|
import part.filters
|
||||||
from build.models import Build, BuildItem
|
from build.models import Build, BuildItem
|
||||||
from build.status_codes import BuildStatusGroups
|
from build.status_codes import BuildStatusGroups
|
||||||
from importer.mixins import DataExportViewMixin
|
from data_exporter.mixins import DataExportViewMixin
|
||||||
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
|
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
|
||||||
from InvenTree.filters import (
|
from InvenTree.filters import (
|
||||||
ORDER_FILTER,
|
ORDER_FILTER,
|
||||||
@ -69,6 +69,17 @@ class CategoryMixin:
|
|||||||
serializer_class = part_serializers.CategorySerializer
|
serializer_class = part_serializers.CategorySerializer
|
||||||
queryset = PartCategory.objects.all()
|
queryset = PartCategory.objects.all()
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
"""Add additional context based on query parameters."""
|
||||||
|
try:
|
||||||
|
params = self.request.query_params
|
||||||
|
|
||||||
|
kwargs['path_detail'] = str2bool(params.get('path_detail', False))
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
"""Return an annotated queryset for the CategoryDetail endpoint."""
|
"""Return an annotated queryset for the CategoryDetail endpoint."""
|
||||||
queryset = super().get_queryset(*args, **kwargs)
|
queryset = super().get_queryset(*args, **kwargs)
|
||||||
@ -248,19 +259,6 @@ class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCrea
|
|||||||
class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI):
|
class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI):
|
||||||
"""API endpoint for detail view of a single PartCategory object."""
|
"""API endpoint for detail view of a single PartCategory object."""
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
|
||||||
"""Add additional context based on query parameters."""
|
|
||||||
try:
|
|
||||||
params = self.request.query_params
|
|
||||||
|
|
||||||
kwargs['path_detail'] = str2bool(params.get('path_detail', False))
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
kwargs.setdefault('context', self.get_serializer_context())
|
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
"""Perform 'update' function and mark this part as 'starred' (or not)."""
|
"""Perform 'update' function and mark this part as 'starred' (or not)."""
|
||||||
# Clean up input data
|
# Clean up input data
|
||||||
@ -1229,7 +1227,7 @@ class PartMixin:
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
"""Extend serializer context data."""
|
"""Extend serializer context data."""
|
||||||
@ -1600,7 +1598,7 @@ class PartParameterAPIMixin:
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class PartParameterFilter(rest_filters.FilterSet):
|
class PartParameterFilter(rest_filters.FilterSet):
|
||||||
@ -1861,7 +1859,7 @@ class BomMixin:
|
|||||||
# Ensure the request context is passed through!
|
# Ensure the request context is passed through!
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
"""Return the queryset object for this endpoint."""
|
"""Return the queryset object for this endpoint."""
|
||||||
|
@ -1,303 +0,0 @@
|
|||||||
"""Functionality for Bill of Material (BOM) management.
|
|
||||||
|
|
||||||
Primarily BOM upload tools.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from .models import Part
|
|
||||||
|
|
||||||
# TODO: 2024-12-17 - This entire file is to be removed
|
|
||||||
# TODO: Ref: https://github.com/inventree/InvenTree/pull/8685
|
|
||||||
# TODO: To be removed as part of https://github.com/inventree/InvenTree/issues/9078
|
|
||||||
|
|
||||||
|
|
||||||
def ExportBom(
|
|
||||||
part: Part,
|
|
||||||
fmt='csv',
|
|
||||||
cascade: bool = False,
|
|
||||||
max_levels: Optional[int] = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
"""Export a BOM (Bill of Materials) for a given part.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
part (Part): Part for which the BOM should be exported
|
|
||||||
fmt (str, optional): file format. Defaults to 'csv'.
|
|
||||||
cascade (bool, optional): If True, multi-level BOM output is supported. Otherwise, a flat top-level-only BOM is exported.. Defaults to False.
|
|
||||||
max_levels (int, optional): Levels of items that should be included. None for np sublevels. Defaults to None.
|
|
||||||
|
|
||||||
kwargs:
|
|
||||||
parameter_data (bool, optional): Additional data that should be added. Defaults to False.
|
|
||||||
stock_data (bool, optional): Additional data that should be added. Defaults to False.
|
|
||||||
supplier_data (bool, optional): Additional data that should be added. Defaults to False.
|
|
||||||
manufacturer_data (bool, optional): Additional data that should be added. Defaults to False.
|
|
||||||
pricing_data (bool, optional): Include pricing data in exported BOM. Defaults to False
|
|
||||||
substitute_part_data (bool, optional): Include substitute part numbers in exported BOM. Defaults to False
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
StreamingHttpResponse: Response that can be passed to the endpoint
|
|
||||||
"""
|
|
||||||
# TODO: All this will be pruned!!!
|
|
||||||
"""
|
|
||||||
parameter_data = str2bool(kwargs.get('parameter_data', False))
|
|
||||||
stock_data = str2bool(kwargs.get('stock_data', False))
|
|
||||||
supplier_data = str2bool(kwargs.get('supplier_data', False))
|
|
||||||
manufacturer_data = str2bool(kwargs.get('manufacturer_data', False))
|
|
||||||
pricing_data = str2bool(kwargs.get('pricing_data', False))
|
|
||||||
substitute_part_data = str2bool(kwargs.get('substitute_part_data', False))
|
|
||||||
|
|
||||||
bom_items = []
|
|
||||||
|
|
||||||
uids = []
|
|
||||||
|
|
||||||
def add_items(items, level, cascade=True):
|
|
||||||
# Add items at a given layer
|
|
||||||
for item in items:
|
|
||||||
item.level = str(int(level))
|
|
||||||
|
|
||||||
# Avoid circular BOM references
|
|
||||||
if item.pk in uids:
|
|
||||||
continue
|
|
||||||
|
|
||||||
bom_items.append(item)
|
|
||||||
|
|
||||||
if cascade and item.sub_part.assembly:
|
|
||||||
if max_levels is None or level < max_levels:
|
|
||||||
add_items(item.sub_part.bom_items.all().order_by('id'), level + 1)
|
|
||||||
|
|
||||||
top_level_items = part.get_bom_items().order_by('id')
|
|
||||||
|
|
||||||
add_items(top_level_items, 1, cascade)
|
|
||||||
|
|
||||||
dataset = BomItemResource().export(
|
|
||||||
queryset=bom_items, cascade=cascade, include_pricing=pricing_data
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_columns_to_dataset(columns, column_size):
|
|
||||||
try:
|
|
||||||
for header, column_dict in columns.items():
|
|
||||||
# Construct column tuple
|
|
||||||
col = tuple(column_dict.get(c_idx, '') for c_idx in range(column_size))
|
|
||||||
# Add column to dataset
|
|
||||||
dataset.append_col(col, header=header)
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if substitute_part_data:
|
|
||||||
# If requested, add extra columns for all substitute part numbers associated with each line item.
|
|
||||||
|
|
||||||
col_index = 0
|
|
||||||
substitute_cols = {}
|
|
||||||
|
|
||||||
for bom_item in bom_items:
|
|
||||||
substitutes = BomItemSubstitute.objects.filter(bom_item=bom_item)
|
|
||||||
for s_idx, substitute in enumerate(substitutes):
|
|
||||||
# Create substitute part IPN column.
|
|
||||||
name = f'{_("Substitute IPN")}{s_idx + 1}'
|
|
||||||
value = substitute.part.IPN
|
|
||||||
try:
|
|
||||||
substitute_cols[name].update({col_index: value})
|
|
||||||
except KeyError:
|
|
||||||
substitute_cols[name] = {col_index: value}
|
|
||||||
|
|
||||||
# Create substitute part name column.
|
|
||||||
name = f'{_("Substitute Part")}{s_idx + 1}'
|
|
||||||
value = substitute.part.name
|
|
||||||
try:
|
|
||||||
substitute_cols[name].update({col_index: value})
|
|
||||||
except KeyError:
|
|
||||||
substitute_cols[name] = {col_index: value}
|
|
||||||
|
|
||||||
# Create substitute part description column.
|
|
||||||
name = f'{_("Substitute Description")}{s_idx + 1}'
|
|
||||||
value = substitute.part.description
|
|
||||||
try:
|
|
||||||
substitute_cols[name].update({col_index: value})
|
|
||||||
except KeyError:
|
|
||||||
substitute_cols[name] = {col_index: value}
|
|
||||||
|
|
||||||
col_index = col_index + 1
|
|
||||||
|
|
||||||
# Add substitute columns to dataset
|
|
||||||
add_columns_to_dataset(substitute_cols, len(bom_items))
|
|
||||||
|
|
||||||
if parameter_data:
|
|
||||||
# If requested, add extra columns for each PartParameter associated with each line item.
|
|
||||||
3
|
|
||||||
parameter_cols = {}
|
|
||||||
|
|
||||||
for b_idx, bom_item in enumerate(bom_items):
|
|
||||||
# Get part parameters
|
|
||||||
parameters = bom_item.sub_part.get_parameters()
|
|
||||||
# Add parameters to columns
|
|
||||||
if parameters:
|
|
||||||
for parameter in parameters:
|
|
||||||
name = parameter.template.name
|
|
||||||
value = parameter.data
|
|
||||||
|
|
||||||
try:
|
|
||||||
parameter_cols[name].update({b_idx: value})
|
|
||||||
except KeyError:
|
|
||||||
parameter_cols[name] = {b_idx: value}
|
|
||||||
|
|
||||||
# Add parameter columns to dataset
|
|
||||||
parameter_cols_ordered = OrderedDict(
|
|
||||||
sorted(parameter_cols.items(), key=lambda x: x[0])
|
|
||||||
)
|
|
||||||
add_columns_to_dataset(parameter_cols_ordered, len(bom_items))
|
|
||||||
|
|
||||||
if stock_data:
|
|
||||||
# If requested, add extra columns for stock data associated with each line item.
|
|
||||||
|
|
||||||
stock_headers = [
|
|
||||||
_('Default Location'),
|
|
||||||
_('Total Stock'),
|
|
||||||
_('Available Stock'),
|
|
||||||
_('On Order'),
|
|
||||||
]
|
|
||||||
|
|
||||||
stock_cols = {}
|
|
||||||
|
|
||||||
for b_idx, bom_item in enumerate(bom_items):
|
|
||||||
stock_data = []
|
|
||||||
|
|
||||||
sub_part = bom_item.sub_part
|
|
||||||
|
|
||||||
# Get part default location
|
|
||||||
try:
|
|
||||||
loc = sub_part.get_default_location()
|
|
||||||
|
|
||||||
if loc is not None:
|
|
||||||
stock_data.append(str(loc.name))
|
|
||||||
else:
|
|
||||||
stock_data.append('')
|
|
||||||
except AttributeError:
|
|
||||||
stock_data.append('')
|
|
||||||
|
|
||||||
# Total "in stock" quantity for this part
|
|
||||||
stock_data.append(str(normalize(sub_part.total_stock)))
|
|
||||||
|
|
||||||
# Total "available stock" quantity for this part
|
|
||||||
stock_data.append(str(normalize(sub_part.available_stock)))
|
|
||||||
|
|
||||||
# Total "on order" quantity for this part
|
|
||||||
stock_data.append(str(normalize(sub_part.on_order)))
|
|
||||||
|
|
||||||
for s_idx, header in enumerate(stock_headers):
|
|
||||||
try:
|
|
||||||
stock_cols[header].update({b_idx: stock_data[s_idx]})
|
|
||||||
except KeyError:
|
|
||||||
stock_cols[header] = {b_idx: stock_data[s_idx]}
|
|
||||||
|
|
||||||
# Add stock columns to dataset
|
|
||||||
add_columns_to_dataset(stock_cols, len(bom_items))
|
|
||||||
|
|
||||||
if manufacturer_data or supplier_data:
|
|
||||||
# If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item.
|
|
||||||
|
|
||||||
# Keep track of the supplier parts we have already exported
|
|
||||||
supplier_parts_used = set()
|
|
||||||
|
|
||||||
manufacturer_cols = {}
|
|
||||||
|
|
||||||
for bom_idx, bom_item in enumerate(bom_items):
|
|
||||||
# Get part instance
|
|
||||||
b_part = bom_item.sub_part
|
|
||||||
|
|
||||||
# Include manufacturer data for each BOM item
|
|
||||||
if manufacturer_data:
|
|
||||||
# Filter manufacturer parts
|
|
||||||
manufacturer_parts = ManufacturerPart.objects.filter(
|
|
||||||
part__pk=b_part.pk
|
|
||||||
).prefetch_related('supplier_parts')
|
|
||||||
|
|
||||||
for mp_idx, mp_part in enumerate(manufacturer_parts):
|
|
||||||
# Extract the "name" field of the Manufacturer (Company)
|
|
||||||
if mp_part and mp_part.manufacturer:
|
|
||||||
manufacturer_name = mp_part.manufacturer.name
|
|
||||||
else:
|
|
||||||
manufacturer_name = ''
|
|
||||||
|
|
||||||
# Extract the "MPN" field from the Manufacturer Part
|
|
||||||
manufacturer_mpn = mp_part.MPN if mp_part else ''
|
|
||||||
|
|
||||||
# Generate a column name for this manufacturer
|
|
||||||
k_man = f'{_("Manufacturer")}_{mp_idx}'
|
|
||||||
k_mpn = f'{_("MPN")}_{mp_idx}'
|
|
||||||
|
|
||||||
try:
|
|
||||||
manufacturer_cols[k_man].update({bom_idx: manufacturer_name})
|
|
||||||
manufacturer_cols[k_mpn].update({bom_idx: manufacturer_mpn})
|
|
||||||
except KeyError:
|
|
||||||
manufacturer_cols[k_man] = {bom_idx: manufacturer_name}
|
|
||||||
manufacturer_cols[k_mpn] = {bom_idx: manufacturer_mpn}
|
|
||||||
|
|
||||||
# We wish to include supplier data for this manufacturer part
|
|
||||||
if supplier_data:
|
|
||||||
for sp_idx, sp_part in enumerate(mp_part.supplier_parts.all()):
|
|
||||||
supplier_parts_used.add(sp_part)
|
|
||||||
|
|
||||||
if sp_part.supplier:
|
|
||||||
supplier_name = sp_part.supplier.name
|
|
||||||
else:
|
|
||||||
supplier_name = ''
|
|
||||||
|
|
||||||
supplier_sku = sp_part.SKU if sp_part else ''
|
|
||||||
|
|
||||||
# Generate column names for this supplier
|
|
||||||
k_sup = (
|
|
||||||
str(_('Supplier'))
|
|
||||||
+ '_'
|
|
||||||
+ str(mp_idx)
|
|
||||||
+ '_'
|
|
||||||
+ str(sp_idx)
|
|
||||||
)
|
|
||||||
k_sku = (
|
|
||||||
str(_('SKU')) + '_' + str(mp_idx) + '_' + str(sp_idx)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
manufacturer_cols[k_sup].update({
|
|
||||||
bom_idx: supplier_name
|
|
||||||
})
|
|
||||||
manufacturer_cols[k_sku].update({bom_idx: supplier_sku})
|
|
||||||
except KeyError:
|
|
||||||
manufacturer_cols[k_sup] = {bom_idx: supplier_name}
|
|
||||||
manufacturer_cols[k_sku] = {bom_idx: supplier_sku}
|
|
||||||
|
|
||||||
if supplier_data:
|
|
||||||
# Add in any extra supplier parts, which are not associated with a manufacturer part
|
|
||||||
|
|
||||||
for sp_idx, sp_part in enumerate(
|
|
||||||
SupplierPart.objects.filter(part__pk=b_part.pk)
|
|
||||||
):
|
|
||||||
if sp_part in supplier_parts_used:
|
|
||||||
continue
|
|
||||||
|
|
||||||
supplier_parts_used.add(sp_part)
|
|
||||||
|
|
||||||
supplier_name = sp_part.supplier.name if sp_part.supplier else ''
|
|
||||||
|
|
||||||
supplier_sku = sp_part.SKU
|
|
||||||
|
|
||||||
# Generate column names for this supplier
|
|
||||||
k_sup = str(_('Supplier')) + '_' + str(sp_idx)
|
|
||||||
k_sku = str(_('SKU')) + '_' + str(sp_idx)
|
|
||||||
|
|
||||||
try:
|
|
||||||
manufacturer_cols[k_sup].update({bom_idx: supplier_name})
|
|
||||||
manufacturer_cols[k_sku].update({bom_idx: supplier_sku})
|
|
||||||
except KeyError:
|
|
||||||
manufacturer_cols[k_sup] = {bom_idx: supplier_name}
|
|
||||||
manufacturer_cols[k_sku] = {bom_idx: supplier_sku}
|
|
||||||
|
|
||||||
# Add supplier columns to dataset
|
|
||||||
add_columns_to_dataset(manufacturer_cols, len(bom_items))
|
|
||||||
|
|
||||||
data = dataset.export(fmt)
|
|
||||||
|
|
||||||
filename = f'{part.full_name}_BOM.{fmt}'
|
|
||||||
|
|
||||||
return DownloadFile(data, filename)
|
|
||||||
"""
|
|
@ -603,9 +603,9 @@ class Part(
|
|||||||
|
|
||||||
This function is exposed to any Validation plugins, and thus can be customized.
|
This function is exposed to any Validation plugins, and thus can be customized.
|
||||||
"""
|
"""
|
||||||
from plugin.registry import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
for plugin in registry.with_mixin('validation'):
|
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||||
# Run the name through each custom validator
|
# Run the name through each custom validator
|
||||||
# If the plugin returns 'True' we will skip any subsequent validation
|
# If the plugin returns 'True' we will skip any subsequent validation
|
||||||
|
|
||||||
@ -625,9 +625,9 @@ class Part(
|
|||||||
- Validation is handled by custom plugins
|
- Validation is handled by custom plugins
|
||||||
- By default, no validation checks are performed
|
- By default, no validation checks are performed
|
||||||
"""
|
"""
|
||||||
from plugin.registry import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
for plugin in registry.with_mixin('validation'):
|
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||||
try:
|
try:
|
||||||
result = plugin.validate_part_ipn(self.IPN, self)
|
result = plugin.validate_part_ipn(self.IPN, self)
|
||||||
|
|
||||||
@ -724,10 +724,10 @@ class Part(
|
|||||||
serial = str(serial).strip()
|
serial = str(serial).strip()
|
||||||
|
|
||||||
# First, throw the serial number against each of the loaded validation plugins
|
# First, throw the serial number against each of the loaded validation plugins
|
||||||
from plugin.registry import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for plugin in registry.with_mixin('validation'):
|
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||||
# Run the serial number through each custom validator
|
# Run the serial number through each custom validator
|
||||||
# If the plugin returns 'True' we will skip any subsequent validation
|
# If the plugin returns 'True' we will skip any subsequent validation
|
||||||
|
|
||||||
@ -844,12 +844,12 @@ class Part(
|
|||||||
Returns:
|
Returns:
|
||||||
The latest serial number specified for this part, or None
|
The latest serial number specified for this part, or None
|
||||||
"""
|
"""
|
||||||
from plugin.registry import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
if allow_plugins:
|
if allow_plugins:
|
||||||
# Check with plugin system
|
# Check with plugin system
|
||||||
# If any plugin returns a non-null result, that takes priority
|
# If any plugin returns a non-null result, that takes priority
|
||||||
for plugin in registry.with_mixin('validation'):
|
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||||
try:
|
try:
|
||||||
result = plugin.get_latest_serial_number(self)
|
result = plugin.get_latest_serial_number(self)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@ -3922,9 +3922,9 @@ class PartParameter(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
self.calculate_numeric_value()
|
self.calculate_numeric_value()
|
||||||
|
|
||||||
# Run custom validation checks (via plugins)
|
# Run custom validation checks (via plugins)
|
||||||
from plugin.registry import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
for plugin in registry.with_mixin('validation'):
|
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||||
# Note: The validate_part_parameter function may raise a ValidationError
|
# Note: The validate_part_parameter function may raise a ValidationError
|
||||||
try:
|
try:
|
||||||
result = plugin.validate_part_parameter(self, self.data)
|
result = plugin.validate_part_parameter(self, self.data)
|
||||||
|
@ -33,8 +33,8 @@ import part.tasks
|
|||||||
import stock.models
|
import stock.models
|
||||||
import users.models
|
import users.models
|
||||||
from build.status_codes import BuildStatusGroups
|
from build.status_codes import BuildStatusGroups
|
||||||
from importer.mixins import DataImportExportSerializerMixin
|
|
||||||
from importer.registry import register_importer
|
from importer.registry import register_importer
|
||||||
|
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||||
from InvenTree.ready import isGeneratingSchema
|
from InvenTree.ready import isGeneratingSchema
|
||||||
from InvenTree.tasks import offload_task
|
from InvenTree.tasks import offload_task
|
||||||
from users.serializers import UserSerializer
|
from users.serializers import UserSerializer
|
||||||
@ -1526,6 +1526,8 @@ class BomItemSerializer(
|
|||||||
|
|
||||||
import_exclude_fields = ['validated', 'substitutes']
|
import_exclude_fields = ['validated', 'substitutes']
|
||||||
|
|
||||||
|
export_exclude_fields = ['substitutes']
|
||||||
|
|
||||||
export_child_fields = [
|
export_child_fields = [
|
||||||
'sub_part_detail.name',
|
'sub_part_detail.name',
|
||||||
'sub_part_detail.IPN',
|
'sub_part_detail.IPN',
|
||||||
|
@ -1180,7 +1180,6 @@ class PartAPITest(PartAPITestBase):
|
|||||||
'Virtual',
|
'Virtual',
|
||||||
'Trackable',
|
'Trackable',
|
||||||
'Active',
|
'Active',
|
||||||
'Notes',
|
|
||||||
'Creation Date',
|
'Creation Date',
|
||||||
'On Order',
|
'On Order',
|
||||||
'In Stock',
|
'In Stock',
|
||||||
@ -1189,9 +1188,9 @@ class PartAPITest(PartAPITestBase):
|
|||||||
|
|
||||||
excluded_cols = ['lft', 'rght', 'level', 'tree_id', 'metadata']
|
excluded_cols = ['lft', 'rght', 'level', 'tree_id', 'metadata']
|
||||||
|
|
||||||
with self.download_file(url, {'export': 'csv'}) as file:
|
with self.export_data(url, export_format='csv') as data_file:
|
||||||
data = self.process_csv(
|
data = self.process_csv(
|
||||||
file,
|
data_file,
|
||||||
excluded_cols=excluded_cols,
|
excluded_cols=excluded_cols,
|
||||||
required_cols=required_cols,
|
required_cols=required_cols,
|
||||||
required_rows=Part.objects.count(),
|
required_rows=Part.objects.count(),
|
||||||
@ -2628,6 +2627,46 @@ class BomItemTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.data['available_variant_stock'], 1000)
|
self.assertEqual(response.data['available_variant_stock'], 1000)
|
||||||
|
|
||||||
|
def test_bom_export(self):
|
||||||
|
"""Test for exporting BOM data."""
|
||||||
|
url = reverse('api-bom-list')
|
||||||
|
|
||||||
|
required_cols = [
|
||||||
|
'Assembly',
|
||||||
|
'Component',
|
||||||
|
'Reference',
|
||||||
|
'Quantity',
|
||||||
|
'Component.Name',
|
||||||
|
'Component.Description',
|
||||||
|
'Available Stock',
|
||||||
|
]
|
||||||
|
|
||||||
|
excluded_cols = ['BOM Level', 'Supplier 1']
|
||||||
|
|
||||||
|
# First, download *all* BOM data, with the default exporter
|
||||||
|
with self.export_data(url, expected_code=200) as data_file:
|
||||||
|
self.process_csv(
|
||||||
|
data_file,
|
||||||
|
required_cols=required_cols,
|
||||||
|
excluded_cols=excluded_cols,
|
||||||
|
required_rows=BomItem.objects.all().count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that the correct exporter plugin has been used
|
||||||
|
required_cols.extend(['BOM Level'])
|
||||||
|
|
||||||
|
# Next, download BOM data for a specific sub-assembly, and use the BOM exporter
|
||||||
|
with self.export_data(
|
||||||
|
url, {'part': 100}, export_plugin='bom-exporter'
|
||||||
|
) as data_file:
|
||||||
|
data = self.process_csv(
|
||||||
|
data_file, required_cols=required_cols, required_rows=4
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in data:
|
||||||
|
self.assertEqual(str(row['Assembly']), '100')
|
||||||
|
self.assertEqual(str(row['BOM Level']), '1')
|
||||||
|
|
||||||
|
|
||||||
class PartAttachmentTest(InvenTreeAPITestCase):
|
class PartAttachmentTest(InvenTreeAPITestCase):
|
||||||
"""Unit tests for the PartAttachment API endpoint."""
|
"""Unit tests for the PartAttachment API endpoint."""
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
"""Utility file to enable simper imports."""
|
"""Utility file to enable simper imports."""
|
||||||
|
|
||||||
from .helpers import MixinImplementationError, MixinNotImplementedError
|
from .helpers import MixinImplementationError, MixinNotImplementedError
|
||||||
from .plugin import InvenTreePlugin
|
from .plugin import InvenTreePlugin, PluginMixinEnum
|
||||||
from .registry import registry
|
from .registry import registry
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'InvenTreePlugin',
|
'InvenTreePlugin',
|
||||||
'MixinImplementationError',
|
'MixinImplementationError',
|
||||||
'MixinNotImplementedError',
|
'MixinNotImplementedError',
|
||||||
|
'PluginMixinEnum',
|
||||||
'registry',
|
'registry',
|
||||||
]
|
]
|
||||||
|
@ -7,7 +7,7 @@ from rest_framework.generics import GenericAPIView
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from InvenTree.exceptions import log_error
|
from InvenTree.exceptions import log_error
|
||||||
from plugin import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
|
|
||||||
class ActionPluginSerializer(serializers.Serializer):
|
class ActionPluginSerializer(serializers.Serializer):
|
||||||
@ -32,7 +32,7 @@ class ActionPluginView(GenericAPIView):
|
|||||||
if action is None:
|
if action is None:
|
||||||
return Response({'error': _('No action specified')})
|
return Response({'error': _('No action specified')})
|
||||||
|
|
||||||
action_plugins = registry.with_mixin('action')
|
action_plugins = registry.with_mixin(PluginMixinEnum.ACTION)
|
||||||
for plugin in action_plugins:
|
for plugin in action_plugins:
|
||||||
try:
|
try:
|
||||||
if plugin.action_name() == action:
|
if plugin.action_name() == action:
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"""Plugin mixin classes for action plugin."""
|
"""Plugin mixin classes for action plugin."""
|
||||||
|
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
|
|
||||||
|
|
||||||
class ActionMixin:
|
class ActionMixin:
|
||||||
"""Mixin that enables custom actions."""
|
"""Mixin that enables custom actions."""
|
||||||
@ -14,7 +16,7 @@ class ActionMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('action', True, __class__)
|
self.add_mixin(PluginMixinEnum.ACTION, True, __class__)
|
||||||
|
|
||||||
def action_name(self):
|
def action_name(self):
|
||||||
"""Action name for this plugin.
|
"""Action name for this plugin.
|
||||||
|
@ -23,7 +23,7 @@ from InvenTree.filters import SEARCH_ORDER_FILTER
|
|||||||
from InvenTree.helpers import hash_barcode
|
from InvenTree.helpers import hash_barcode
|
||||||
from InvenTree.mixins import ListAPI, RetrieveDestroyAPI
|
from InvenTree.mixins import ListAPI, RetrieveDestroyAPI
|
||||||
from InvenTree.permissions import IsStaffOrReadOnly
|
from InvenTree.permissions import IsStaffOrReadOnly
|
||||||
from plugin import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
from users.models import RuleSet
|
from users.models import RuleSet
|
||||||
|
|
||||||
from . import serializers as barcode_serializers
|
from . import serializers as barcode_serializers
|
||||||
@ -143,7 +143,7 @@ class BarcodeView(CreateAPIView):
|
|||||||
|
|
||||||
Check each loaded plugin, and return the first valid match
|
Check each loaded plugin, and return the first valid match
|
||||||
"""
|
"""
|
||||||
plugins = registry.with_mixin('barcode')
|
plugins = registry.with_mixin(PluginMixinEnum.BARCODE)
|
||||||
|
|
||||||
# Look for a barcode plugin which knows how to deal with this barcode
|
# Look for a barcode plugin which knows how to deal with this barcode
|
||||||
plugin = None
|
plugin = None
|
||||||
@ -521,7 +521,7 @@ class BarcodePOReceive(BarcodeView):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
plugins = registry.with_mixin('barcode')
|
plugins = registry.with_mixin(PluginMixinEnum.BARCODE)
|
||||||
|
|
||||||
# Look for a barcode plugin which knows how to deal with this barcode
|
# Look for a barcode plugin which knows how to deal with this barcode
|
||||||
plugin = None
|
plugin = None
|
||||||
@ -539,7 +539,7 @@ class BarcodePOReceive(BarcodeView):
|
|||||||
raise ValidationError(response)
|
raise ValidationError(response)
|
||||||
|
|
||||||
# Now, look just for "supplier-barcode" plugins
|
# Now, look just for "supplier-barcode" plugins
|
||||||
plugins = registry.with_mixin('supplier-barcode')
|
plugins = registry.with_mixin(PluginMixinEnum.SUPPLIER_BARCODE)
|
||||||
|
|
||||||
plugin_response = None
|
plugin_response = None
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ from InvenTree.exceptions import log_error
|
|||||||
from InvenTree.models import InvenTreeBarcodeMixin
|
from InvenTree.models import InvenTreeBarcodeMixin
|
||||||
from order.models import PurchaseOrder
|
from order.models import PurchaseOrder
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
from plugin.base.integration.SettingsMixin import SettingsMixin
|
from plugin.base.integration.SettingsMixin import SettingsMixin
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
@ -34,7 +35,7 @@ class BarcodeMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('barcode', 'has_barcode', __class__)
|
self.add_mixin(PluginMixinEnum.BARCODE, 'has_barcode', __class__)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_barcode(self):
|
def has_barcode(self):
|
||||||
@ -104,7 +105,7 @@ class SupplierBarcodeMixin(BarcodeMixin):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('supplier-barcode', True, __class__)
|
self.add_mixin(PluginMixinEnum.SUPPLIER_BARCODE, True, __class__)
|
||||||
|
|
||||||
def get_field_value(self, key, backup_value=None):
|
def get_field_value(self, key, backup_value=None):
|
||||||
"""Return the value of a barcode field."""
|
"""Return the value of a barcode field."""
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"""Plugin mixin class for events."""
|
"""Plugin mixin class for events."""
|
||||||
|
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
from plugin.helpers import MixinNotImplementedError
|
from plugin.helpers import MixinNotImplementedError
|
||||||
|
|
||||||
|
|
||||||
@ -33,4 +34,4 @@ class EventMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register the mixin."""
|
"""Register the mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('events', True, __class__)
|
self.add_mixin(PluginMixinEnum.EVENTS, True, __class__)
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from common.icons import IconPack, reload_icon_packs
|
from common.icons import IconPack, reload_icon_packs
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
from plugin.helpers import MixinNotImplementedError
|
from plugin.helpers import MixinNotImplementedError
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
@ -19,7 +20,7 @@ class IconPackMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('icon_pack', True, __class__)
|
self.add_mixin(PluginMixinEnum.ICON_PACK, True, __class__)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _activate_mixin(cls, registry, plugins, *args, **kwargs):
|
def _activate_mixin(cls, registry, plugins, *args, **kwargs):
|
||||||
|
@ -7,6 +7,7 @@ from typing import Optional
|
|||||||
import requests
|
import requests
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
from plugin.helpers import MixinNotImplementedError
|
from plugin.helpers import MixinNotImplementedError
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
@ -72,7 +73,7 @@ class APICallMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('api_call', 'has_api_call', __class__)
|
self.add_mixin(PluginMixinEnum.API_CALL, 'has_api_call', __class__)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_api_call(self):
|
def has_api_call(self):
|
||||||
|
@ -11,6 +11,7 @@ from django.contrib import admin
|
|||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from InvenTree.config import get_plugin_dir
|
from InvenTree.config import get_plugin_dir
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
@ -26,7 +27,7 @@ class AppMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('app', 'has_app', __class__)
|
self.add_mixin(PluginMixinEnum.APP, 'has_app', __class__)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _activate_mixin(
|
def _activate_mixin(
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"""Plugin mixin class for supporting currency exchange data."""
|
"""Plugin mixin class for supporting currency exchange data."""
|
||||||
|
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
from plugin.helpers import MixinNotImplementedError
|
from plugin.helpers import MixinNotImplementedError
|
||||||
|
|
||||||
|
|
||||||
@ -21,7 +22,7 @@ class CurrencyExchangeMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register the mixin."""
|
"""Register the mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('currencyexchange', True, __class__)
|
self.add_mixin(PluginMixinEnum.CURRENCY_EXCHANGE, True, __class__)
|
||||||
|
|
||||||
def update_exchange_rates(self, base_currency: str, symbols: list[str]) -> dict:
|
def update_exchange_rates(self, base_currency: str, symbols: list[str]) -> dict:
|
||||||
"""Update currency exchange rates.
|
"""Update currency exchange rates.
|
||||||
|
115
src/backend/InvenTree/plugin/base/integration/DataExport.py
Normal file
115
src/backend/InvenTree/plugin/base/integration/DataExport.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"""Plugin class for custom data exporting."""
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from common.models import DataOutput
|
||||||
|
from InvenTree.helpers import current_date
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
|
|
||||||
|
|
||||||
|
class DataExportMixin:
|
||||||
|
"""Mixin which provides ability to customize data exports.
|
||||||
|
|
||||||
|
When exporting data from the API, this mixin can be used to provide
|
||||||
|
custom data export functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ExportOptionsSerializer = None
|
||||||
|
|
||||||
|
class MixinMeta:
|
||||||
|
"""Meta options for this mixin."""
|
||||||
|
|
||||||
|
MIXIN_NAME = 'DataExport'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Register mixin."""
|
||||||
|
super().__init__()
|
||||||
|
self.add_mixin(PluginMixinEnum.EXPORTER, True, __class__)
|
||||||
|
|
||||||
|
def supports_export(self, model_class: type, user: User, *args, **kwargs) -> bool:
|
||||||
|
"""Return True if this plugin supports exporting data for the given model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_class: The model class to check
|
||||||
|
user: The user requesting the export
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the plugin supports exporting data for the given model
|
||||||
|
"""
|
||||||
|
# By default, plugins support all models
|
||||||
|
return True
|
||||||
|
|
||||||
|
def generate_filename(self, model_class, export_format: str) -> str:
|
||||||
|
"""Generate a filename for the exported data."""
|
||||||
|
model = model_class.__name__
|
||||||
|
date = current_date().isoformat()
|
||||||
|
|
||||||
|
return f'InvenTree_{model}_{date}.{export_format}'
|
||||||
|
|
||||||
|
def update_headers(
|
||||||
|
self, headers: OrderedDict, context: dict, **kwargs
|
||||||
|
) -> OrderedDict:
|
||||||
|
"""Update the headers for the data export.
|
||||||
|
|
||||||
|
Allows for optional modification of the headers for the data export.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
headers: The current headers for the export
|
||||||
|
context: The context for the export (provided by the plugin serializer)
|
||||||
|
|
||||||
|
Returns: The updated headers
|
||||||
|
"""
|
||||||
|
# The default implementation does nothing
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def filter_queryset(self, queryset: QuerySet) -> QuerySet:
|
||||||
|
"""Filter the queryset before exporting data."""
|
||||||
|
# The default implementation returns the queryset unchanged
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def export_data(
|
||||||
|
self,
|
||||||
|
queryset: QuerySet,
|
||||||
|
serializer_class: serializers.Serializer,
|
||||||
|
headers: OrderedDict,
|
||||||
|
context: dict,
|
||||||
|
output: DataOutput,
|
||||||
|
**kwargs,
|
||||||
|
) -> list:
|
||||||
|
"""Export data from the queryset.
|
||||||
|
|
||||||
|
This method should be implemented by the plugin to provide
|
||||||
|
the actual data export functionality.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
queryset: The queryset to export
|
||||||
|
serializer_class: The serializer class to use for exporting the data
|
||||||
|
headers: The headers for the export
|
||||||
|
context: Any custom context for the export (provided by the plugin serializer)
|
||||||
|
output: The DataOutput object for the export
|
||||||
|
|
||||||
|
Returns: The exported data (a list of dict objects)
|
||||||
|
"""
|
||||||
|
# The default implementation simply serializes the queryset
|
||||||
|
return serializer_class(queryset, many=True, exporting=True).data
|
||||||
|
|
||||||
|
def get_export_options_serializer(
|
||||||
|
self, **kwargs
|
||||||
|
) -> Union[serializers.Serializer, None]:
|
||||||
|
"""Return a serializer class with dynamic export options for this plugin.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A class instance of a DRF serializer class, by default this an instance of
|
||||||
|
self.ExportOptionsSerializer using the *args, **kwargs if existing for this plugin
|
||||||
|
"""
|
||||||
|
# By default, look for a class level attribute
|
||||||
|
serializer = getattr(self, 'ExportOptionsSerializer', None)
|
||||||
|
|
||||||
|
if serializer:
|
||||||
|
return serializer(**kwargs)
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
from plugin.helpers import MixinNotImplementedError
|
from plugin.helpers import MixinNotImplementedError
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
@ -21,7 +22,7 @@ class NavigationMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('navigation', 'has_navigation', __class__)
|
self.add_mixin(PluginMixinEnum.NAVIGATION, 'has_navigation', __class__)
|
||||||
self.navigation = self.setup_navigation()
|
self.navigation = self.setup_navigation()
|
||||||
|
|
||||||
def setup_navigation(self):
|
def setup_navigation(self):
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"""Plugin mixin class for ReportContextMixin."""
|
"""Plugin mixin class for ReportContextMixin."""
|
||||||
|
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
|
|
||||||
|
|
||||||
class ReportMixin:
|
class ReportMixin:
|
||||||
"""Mixin which provides additional context to generated reports.
|
"""Mixin which provides additional context to generated reports.
|
||||||
@ -20,7 +22,7 @@ class ReportMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('report', True, __class__)
|
self.add_mixin(PluginMixinEnum.REPORT, True, __class__)
|
||||||
|
|
||||||
def add_report_context(self, report_instance, model_instance, request, context):
|
def add_report_context(self, report_instance, model_instance, request, context):
|
||||||
"""Add extra context to the provided report instance.
|
"""Add extra context to the provided report instance.
|
||||||
|
@ -6,6 +6,7 @@ from django.db.utils import OperationalError, ProgrammingError
|
|||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
from plugin.helpers import MixinImplementationError
|
from plugin.helpers import MixinImplementationError
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
@ -54,7 +55,7 @@ class ScheduleMixin:
|
|||||||
|
|
||||||
self.scheduled_tasks = []
|
self.scheduled_tasks = []
|
||||||
|
|
||||||
self.add_mixin('schedule', 'has_scheduled_tasks', __class__)
|
self.add_mixin(PluginMixinEnum.SCHEDULE, 'has_scheduled_tasks', __class__)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _activate_mixin(cls, registry, plugins, *args, **kwargs):
|
def _activate_mixin(cls, registry, plugins, *args, **kwargs):
|
||||||
|
@ -6,6 +6,8 @@ from django.db.utils import OperationalError, ProgrammingError
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
# import only for typechecking, otherwise this throws a model is unready error
|
# import only for typechecking, otherwise this throws a model is unready error
|
||||||
@ -30,7 +32,7 @@ class SettingsMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('settings', 'has_settings', __class__)
|
self.add_mixin(PluginMixinEnum.SETTINGS, 'has_settings', __class__)
|
||||||
self.settings = getattr(self, 'SETTINGS', {})
|
self.settings = getattr(self, 'SETTINGS', {})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -6,6 +6,7 @@ from django.urls import include, re_path
|
|||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
from plugin.urls import PLUGIN_BASE
|
from plugin.urls import PLUGIN_BASE
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
@ -22,7 +23,7 @@ class UrlsMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('urls', 'has_urls', __class__)
|
self.add_mixin(PluginMixinEnum.URLS, 'has_urls', __class__)
|
||||||
self.urls = self.setup_urls()
|
self.urls = self.setup_urls()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -7,6 +7,7 @@ from django.db.models import Model
|
|||||||
|
|
||||||
import part.models
|
import part.models
|
||||||
import stock.models
|
import stock.models
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
|
|
||||||
|
|
||||||
class ValidationMixin:
|
class ValidationMixin:
|
||||||
@ -46,7 +47,7 @@ class ValidationMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register the mixin."""
|
"""Register the mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('validation', True, __class__)
|
self.add_mixin(PluginMixinEnum.VALIDATION, True, __class__)
|
||||||
|
|
||||||
def raise_error(self, message):
|
def raise_error(self, message):
|
||||||
"""Raise a ValidationError with the given message."""
|
"""Raise a ValidationError with the given message."""
|
||||||
|
@ -9,12 +9,13 @@ import pdf2image
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import DataOutput, InvenTreeSetting
|
||||||
from InvenTree.exceptions import log_error
|
from InvenTree.exceptions import log_error
|
||||||
from InvenTree.tasks import offload_task
|
from InvenTree.tasks import offload_task
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
from plugin.base.label import label as plugin_label
|
from plugin.base.label import label as plugin_label
|
||||||
from plugin.helpers import MixinNotImplementedError
|
from plugin.helpers import MixinNotImplementedError
|
||||||
from report.models import LabelTemplate, TemplateOutput
|
from report.models import LabelTemplate
|
||||||
|
|
||||||
|
|
||||||
class LabelPrintingMixin:
|
class LabelPrintingMixin:
|
||||||
@ -35,7 +36,7 @@ class LabelPrintingMixin:
|
|||||||
def __init__(self): # pragma: no cover
|
def __init__(self): # pragma: no cover
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('labels', True, __class__)
|
self.add_mixin(PluginMixinEnum.LABELS, True, __class__)
|
||||||
|
|
||||||
BLOCKING_PRINT = True
|
BLOCKING_PRINT = True
|
||||||
|
|
||||||
@ -104,7 +105,7 @@ class LabelPrintingMixin:
|
|||||||
def print_labels(
|
def print_labels(
|
||||||
self,
|
self,
|
||||||
label: LabelTemplate,
|
label: LabelTemplate,
|
||||||
output: TemplateOutput,
|
output: DataOutput,
|
||||||
items: list,
|
items: list,
|
||||||
request: Request,
|
request: Request,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
@ -113,7 +114,7 @@ class LabelPrintingMixin:
|
|||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
label: The LabelTemplate object to use for printing
|
label: The LabelTemplate object to use for printing
|
||||||
output: The TemplateOutput object used to store the results
|
output: The DataOutput object used to store the results
|
||||||
items: The list of database items to print (e.g. StockItem instances)
|
items: The list of database items to print (e.g. StockItem instances)
|
||||||
request: The HTTP request object which triggered this print job
|
request: The HTTP request object which triggered this print job
|
||||||
|
|
||||||
@ -121,7 +122,7 @@ class LabelPrintingMixin:
|
|||||||
printing_options: The printing options set for this print job defined in the PrintingOptionsSerializer
|
printing_options: The printing options set for this print job defined in the PrintingOptionsSerializer
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
None. Output data should be stored in the provided TemplateOutput object
|
None. Output data should be stored in the provided DataOutput object
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError if there is an error during the print process
|
ValidationError if there is an error during the print process
|
||||||
@ -211,7 +212,7 @@ class LabelPrintingMixin:
|
|||||||
filename: The filename of this PDF label
|
filename: The filename of this PDF label
|
||||||
label_instance: The instance of the label model which triggered the print_label() method
|
label_instance: The instance of the label model which triggered the print_label() method
|
||||||
item_instance: The instance of the database model against which the label is printed
|
item_instance: The instance of the database model against which the label is printed
|
||||||
output: The TemplateOutput object used to store the results of the print job
|
output: The DataOutput object used to store the results of the print job
|
||||||
user: The user who triggered this print job
|
user: The user who triggered this print job
|
||||||
width: The expected width of the label (in mm)
|
width: The expected width of the label (in mm)
|
||||||
height: The expected height of the label (in mm)
|
height: The expected height of the label (in mm)
|
||||||
|
@ -13,10 +13,9 @@ from PIL import Image
|
|||||||
from InvenTree.settings import BASE_DIR
|
from InvenTree.settings import BASE_DIR
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
from plugin import InvenTreePlugin, PluginMixinEnum, registry
|
||||||
from plugin.base.label.mixins import LabelPrintingMixin
|
from plugin.base.label.mixins import LabelPrintingMixin
|
||||||
from plugin.helpers import MixinNotImplementedError
|
from plugin.helpers import MixinNotImplementedError
|
||||||
from plugin.plugin import InvenTreePlugin
|
|
||||||
from plugin.registry import registry
|
|
||||||
from report.models import LabelTemplate
|
from report.models import LabelTemplate
|
||||||
from report.tests import PrintTestMixins
|
from report.tests import PrintTestMixins
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
@ -48,11 +47,11 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
|
|||||||
def test_installed(self):
|
def test_installed(self):
|
||||||
"""Test that the sample printing plugin is installed."""
|
"""Test that the sample printing plugin is installed."""
|
||||||
# Get all label plugins
|
# Get all label plugins
|
||||||
plugins = registry.with_mixin('labels', active=None)
|
plugins = registry.with_mixin(PluginMixinEnum.LABELS, active=None)
|
||||||
self.assertEqual(len(plugins), 4)
|
self.assertEqual(len(plugins), 4)
|
||||||
|
|
||||||
# But, it is not 'active'
|
# But, it is not 'active'
|
||||||
plugins = registry.with_mixin('labels', active=True)
|
plugins = registry.with_mixin(PluginMixinEnum.LABELS, active=True)
|
||||||
self.assertEqual(len(plugins), 3)
|
self.assertEqual(len(plugins), 3)
|
||||||
|
|
||||||
def test_api(self):
|
def test_api(self):
|
||||||
@ -121,7 +120,7 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
|
|||||||
self.assertIn('Plugin does not support label printing', str(response.data))
|
self.assertIn('Plugin does not support label printing', str(response.data))
|
||||||
|
|
||||||
# Find available plugins
|
# Find available plugins
|
||||||
plugins = registry.with_mixin('labels')
|
plugins = registry.with_mixin(PluginMixinEnum.LABELS)
|
||||||
self.assertGreater(len(plugins), 0)
|
self.assertGreater(len(plugins), 0)
|
||||||
|
|
||||||
plugin = registry.get_plugin('samplelabelprinter')
|
plugin = registry.get_plugin('samplelabelprinter')
|
||||||
|
@ -7,7 +7,8 @@ from rest_framework.response import Response
|
|||||||
|
|
||||||
from InvenTree.exceptions import log_error
|
from InvenTree.exceptions import log_error
|
||||||
from InvenTree.tasks import offload_task
|
from InvenTree.tasks import offload_task
|
||||||
from plugin.registry import call_plugin_function, registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
from plugin.registry import call_plugin_function
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
|
|
||||||
|
|
||||||
@ -39,7 +40,7 @@ class LocatePluginView(GenericAPIView):
|
|||||||
raise ParseError("'plugin' field must be supplied")
|
raise ParseError("'plugin' field must be supplied")
|
||||||
|
|
||||||
# Check that the plugin exists, and supports the 'locate' mixin
|
# Check that the plugin exists, and supports the 'locate' mixin
|
||||||
plugins = registry.with_mixin('locate')
|
plugins = registry.with_mixin(PluginMixinEnum.LOCATE)
|
||||||
|
|
||||||
if plugin not in [p.slug for p in plugins]:
|
if plugin not in [p.slug for p in plugins]:
|
||||||
raise ParseError(
|
raise ParseError(
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
from plugin.helpers import MixinNotImplementedError
|
from plugin.helpers import MixinNotImplementedError
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
@ -31,7 +32,7 @@ class LocateMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register the mixin."""
|
"""Register the mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('locate', True, __class__)
|
self.add_mixin(PluginMixinEnum.LOCATE, True, __class__)
|
||||||
|
|
||||||
def locate_stock_item(self, item_pk):
|
def locate_stock_item(self, item_pk):
|
||||||
"""Attempt to locate a particular StockItem.
|
"""Attempt to locate a particular StockItem.
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
from plugin import InvenTreePlugin, MixinNotImplementedError, registry
|
from plugin import InvenTreePlugin, MixinNotImplementedError, PluginMixinEnum, registry
|
||||||
from plugin.base.locate.mixins import LocateMixin
|
from plugin.base.locate.mixins import LocateMixin
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ class LocatePluginTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
def test_installed(self):
|
def test_installed(self):
|
||||||
"""Test that a locate plugin is actually installed."""
|
"""Test that a locate plugin is actually installed."""
|
||||||
plugins = registry.with_mixin('locate')
|
plugins = registry.with_mixin(PluginMixinEnum.LOCATE)
|
||||||
|
|
||||||
self.assertGreater(len(plugins), 0)
|
self.assertGreater(len(plugins), 0)
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ from rest_framework.views import APIView
|
|||||||
import plugin.base.ui.serializers as UIPluginSerializers
|
import plugin.base.ui.serializers as UIPluginSerializers
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
from InvenTree.exceptions import log_error
|
from InvenTree.exceptions import log_error
|
||||||
from plugin import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
|
|
||||||
class PluginUIFeatureList(APIView):
|
class PluginUIFeatureList(APIView):
|
||||||
@ -28,7 +28,9 @@ class PluginUIFeatureList(APIView):
|
|||||||
|
|
||||||
if get_global_setting('ENABLE_PLUGINS_INTERFACE'):
|
if get_global_setting('ENABLE_PLUGINS_INTERFACE'):
|
||||||
# Extract all plugins from the registry which provide custom ui features
|
# Extract all plugins from the registry which provide custom ui features
|
||||||
for _plugin in registry.with_mixin('ui', active=True):
|
for _plugin in registry.with_mixin(
|
||||||
|
PluginMixinEnum.USER_INTERFACE, active=True
|
||||||
|
):
|
||||||
# Allow plugins to fill this data out
|
# Allow plugins to fill this data out
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -8,6 +8,8 @@ from typing import Literal, TypedDict
|
|||||||
import structlog
|
import structlog
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
|
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
|
||||||
@ -79,7 +81,7 @@ class UserInterfaceMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('ui', True, __class__) # type: ignore
|
self.add_mixin(PluginMixinEnum.USER_INTERFACE, True, __class__) # type: ignore
|
||||||
|
|
||||||
def get_ui_features(
|
def get_ui_features(
|
||||||
self, feature_type: FeatureType, context: dict, request: Request, **kwargs
|
self, feature_type: FeatureType, context: dict, request: Request, **kwargs
|
||||||
|
@ -4,7 +4,7 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
from plugin.registry import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
|
|
||||||
class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
||||||
@ -30,7 +30,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
|
|||||||
plugin = registry.get_plugin('sampleui')
|
plugin = registry.get_plugin('sampleui')
|
||||||
self.assertTrue(plugin.is_active())
|
self.assertTrue(plugin.is_active())
|
||||||
|
|
||||||
plugins = registry.with_mixin('ui')
|
plugins = registry.with_mixin(PluginMixinEnum.USER_INTERFACE)
|
||||||
self.assertGreater(len(plugins), 0)
|
self.assertGreater(len(plugins), 0)
|
||||||
|
|
||||||
def test_ui_dashboard_items(self):
|
def test_ui_dashboard_items(self):
|
||||||
|
286
src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py
Normal file
286
src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
"""Multi-level BOM exporter plugin."""
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
import rest_framework.serializers as serializers
|
||||||
|
|
||||||
|
from part.models import BomItem
|
||||||
|
from plugin import InvenTreePlugin
|
||||||
|
from plugin.mixins import DataExportMixin
|
||||||
|
|
||||||
|
|
||||||
|
class BomExporterOptionsSerializer(serializers.Serializer):
|
||||||
|
"""Custom export options for the BOM exporter plugin."""
|
||||||
|
|
||||||
|
export_levels = serializers.IntegerField(
|
||||||
|
default=1,
|
||||||
|
label=_('Levels'),
|
||||||
|
help_text=_('Number of levels to export'),
|
||||||
|
min_value=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
export_stock_data = serializers.BooleanField(
|
||||||
|
default=True, label=_('Stock Data'), help_text=_('Include part stock data')
|
||||||
|
)
|
||||||
|
|
||||||
|
export_pricing_data = serializers.BooleanField(
|
||||||
|
default=True, label=_('Pricing Data'), help_text=_('Include part pricing data')
|
||||||
|
)
|
||||||
|
|
||||||
|
export_supplier_data = serializers.BooleanField(
|
||||||
|
default=True, label=_('Supplier Data'), help_text=_('Include supplier data')
|
||||||
|
)
|
||||||
|
|
||||||
|
export_manufacturer_data = serializers.BooleanField(
|
||||||
|
default=True,
|
||||||
|
label=_('Manufacturer Data'),
|
||||||
|
help_text=_('Include manufacturer data'),
|
||||||
|
)
|
||||||
|
|
||||||
|
export_substitute_data = serializers.BooleanField(
|
||||||
|
default=True,
|
||||||
|
label=_('Substitute Data'),
|
||||||
|
help_text=_('Include substitute part data'),
|
||||||
|
)
|
||||||
|
|
||||||
|
export_parameter_data = serializers.BooleanField(
|
||||||
|
default=True,
|
||||||
|
label=_('Parameter Data'),
|
||||||
|
help_text=_('Include part parameter data'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BomExporterPlugin(DataExportMixin, InvenTreePlugin):
|
||||||
|
"""Builtin plugin for performing multi-level BOM exports."""
|
||||||
|
|
||||||
|
NAME = 'BOM Exporter'
|
||||||
|
SLUG = 'bom-exporter'
|
||||||
|
TITLE = _('Multi-Level BOM Exporter')
|
||||||
|
DESCRIPTION = _('Provides support for exporting multi-level BOMs')
|
||||||
|
VERSION = '1.0.0'
|
||||||
|
AUTHOR = _('InvenTree contributors')
|
||||||
|
|
||||||
|
ExportOptionsSerializer = BomExporterOptionsSerializer
|
||||||
|
|
||||||
|
def supports_export(self, model_class: type, user, *args, **kwargs) -> bool:
|
||||||
|
"""This exported only supports the BomItem model."""
|
||||||
|
return model_class == BomItem
|
||||||
|
|
||||||
|
def update_headers(self, headers, context, **kwargs):
|
||||||
|
"""Update headers for the BOM export."""
|
||||||
|
if not self.export_stock_data:
|
||||||
|
# Remove stock data from the headers
|
||||||
|
for field in [
|
||||||
|
'available_stock',
|
||||||
|
'available_substitute_stock',
|
||||||
|
'available_variant_stock',
|
||||||
|
'external_stock',
|
||||||
|
'on_order',
|
||||||
|
'building',
|
||||||
|
'can_build',
|
||||||
|
]:
|
||||||
|
headers.pop(field, None)
|
||||||
|
|
||||||
|
if not self.export_pricing_data:
|
||||||
|
# Remove pricing data from the headers
|
||||||
|
for field in [
|
||||||
|
'pricing_min',
|
||||||
|
'pricing_max',
|
||||||
|
'pricing_min_total',
|
||||||
|
'pricing_max_total',
|
||||||
|
'pricing_updated',
|
||||||
|
]:
|
||||||
|
headers.pop(field, None)
|
||||||
|
|
||||||
|
# Append a "BOM Level" field
|
||||||
|
headers['level'] = _('BOM Level')
|
||||||
|
|
||||||
|
# Append variant part columns
|
||||||
|
if self.export_substitute_data and self.n_substitute_cols > 0:
|
||||||
|
for idx in range(self.n_substitute_cols):
|
||||||
|
n = idx + 1
|
||||||
|
headers[f'substitute_{idx}'] = _(f'Substitute {n}')
|
||||||
|
|
||||||
|
# Append supplier part columns
|
||||||
|
if self.export_supplier_data and self.n_supplier_cols > 0:
|
||||||
|
for idx in range(self.n_supplier_cols):
|
||||||
|
n = idx + 1
|
||||||
|
headers[f'supplier_name_{idx}'] = _(f'Supplier {n}')
|
||||||
|
headers[f'supplier_sku_{idx}'] = _(f'Supplier {n} SKU')
|
||||||
|
headers[f'supplier_mpn_{idx}'] = _(f'Supplier {n} MPN')
|
||||||
|
|
||||||
|
# Append manufacturer part columns
|
||||||
|
if self.export_manufacturer_data and self.n_manufacturer_cols > 0:
|
||||||
|
for idx in range(self.n_manufacturer_cols):
|
||||||
|
n = idx + 1
|
||||||
|
headers[f'manufacturer_name_{idx}'] = _(f'Manufacturer {n}')
|
||||||
|
headers[f'manufacturer_mpn_{idx}'] = _(f'Manufacturer {n} MPN')
|
||||||
|
|
||||||
|
# Append part parameter columns
|
||||||
|
if self.export_parameter_data and len(self.parameters) > 0:
|
||||||
|
for key, value in self.parameters.items():
|
||||||
|
headers[f'parameter_{key}'] = value
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def prefetch_queryset(self, queryset):
|
||||||
|
"""Perform pre-fetch on the provided queryset."""
|
||||||
|
queryset = queryset.prefetch_related('sub_part')
|
||||||
|
|
||||||
|
if self.export_substitute_data:
|
||||||
|
queryset = queryset.prefetch_related('substitutes')
|
||||||
|
|
||||||
|
if self.export_supplier_data:
|
||||||
|
queryset = queryset.prefetch_related('sub_part__supplier_parts')
|
||||||
|
queryset = queryset.prefetch_related(
|
||||||
|
'sub_part__supplier_parts__manufacturer_part'
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.export_manufacturer_data:
|
||||||
|
queryset = queryset.prefetch_related('sub_part__manufacturer_parts')
|
||||||
|
|
||||||
|
if self.export_parameter_data:
|
||||||
|
queryset = queryset.prefetch_related('sub_part__parameters')
|
||||||
|
queryset = queryset.prefetch_related('sub_part__parameters__template')
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def export_data(
|
||||||
|
self, queryset, serializer_class, headers, context, output, **kwargs
|
||||||
|
):
|
||||||
|
"""Export BOM data from the queryset."""
|
||||||
|
self.serializer_class = serializer_class
|
||||||
|
|
||||||
|
# Track how many extra columns we need
|
||||||
|
self.n_substitute_cols = 0
|
||||||
|
self.n_supplier_cols = 0
|
||||||
|
self.n_manufacturer_cols = 0
|
||||||
|
|
||||||
|
# A dict of "Parameter ID" -> "Parameter Name"
|
||||||
|
self.parameters = {}
|
||||||
|
|
||||||
|
# Extract the export options from the context (and cache for later)
|
||||||
|
self.export_levels = context.get('export_levels', 1)
|
||||||
|
self.export_stock_data = context.get('export_stock_data', True)
|
||||||
|
self.export_pricing_data = context.get('export_pricing_data', True)
|
||||||
|
self.export_supplier_data = context.get('export_supplier_data', True)
|
||||||
|
self.export_manufacturer_data = context.get('export_manufacturer_data', True)
|
||||||
|
self.export_substitute_data = context.get('export_substitute_data', True)
|
||||||
|
self.export_parameter_data = context.get('export_parameter_data', True)
|
||||||
|
|
||||||
|
# Pre-fetch related data to reduce database queries
|
||||||
|
queryset = self.prefetch_queryset(queryset)
|
||||||
|
|
||||||
|
self.bom_data = []
|
||||||
|
|
||||||
|
# Run through each item in the queryset
|
||||||
|
for bom_item in queryset:
|
||||||
|
self.process_bom_row(bom_item, 1, **kwargs)
|
||||||
|
|
||||||
|
return self.bom_data
|
||||||
|
|
||||||
|
def process_bom_row(self, bom_item, level, **kwargs) -> list:
|
||||||
|
"""Process a single BOM row.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
bom_item: The BomItem object to process
|
||||||
|
level: The current level of export
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Add this row to the output dataset
|
||||||
|
row = self.serializer_class(bom_item, exporting=True).data
|
||||||
|
row['level'] = level
|
||||||
|
|
||||||
|
# Extend with additional data
|
||||||
|
|
||||||
|
if self.export_substitute_data:
|
||||||
|
row.update(self.get_substitute_data(bom_item))
|
||||||
|
|
||||||
|
if self.export_supplier_data:
|
||||||
|
row.update(self.get_supplier_data(bom_item))
|
||||||
|
|
||||||
|
if self.export_manufacturer_data:
|
||||||
|
row.update(self.get_manufacturer_data(bom_item))
|
||||||
|
|
||||||
|
if self.export_parameter_data:
|
||||||
|
row.update(self.get_parameter_data(bom_item))
|
||||||
|
|
||||||
|
self.bom_data.append(row)
|
||||||
|
|
||||||
|
# If we have reached the maximum export level, return just this bom item
|
||||||
|
if bom_item.sub_part.assembly and (
|
||||||
|
self.export_levels <= 0 or level < self.export_levels
|
||||||
|
):
|
||||||
|
sub_items = bom_item.sub_part.get_bom_items()
|
||||||
|
sub_items = self.prefetch_queryset(sub_items)
|
||||||
|
|
||||||
|
for item in sub_items.all():
|
||||||
|
self.process_bom_row(item, level + 1, **kwargs)
|
||||||
|
|
||||||
|
def get_substitute_data(self, bom_item: BomItem) -> dict:
|
||||||
|
"""Return substitute part data for a BomItem."""
|
||||||
|
substitute_part_data = {}
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
|
||||||
|
for substitute in bom_item.substitutes.all():
|
||||||
|
substitute_part_data.update({f'substitute_{idx}': substitute.part.name})
|
||||||
|
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
self.n_substitute_cols = max(self.n_substitute_cols, idx)
|
||||||
|
|
||||||
|
return substitute_part_data
|
||||||
|
|
||||||
|
def get_supplier_data(self, bom_item: BomItem) -> dict:
|
||||||
|
"""Return supplier and manufacturer data for a BomItem."""
|
||||||
|
supplier_part_data = {}
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
|
||||||
|
for supplier_part in bom_item.sub_part.supplier_parts.all():
|
||||||
|
manufacturer_part = supplier_part.manufacturer_part
|
||||||
|
supplier_part_data.update({
|
||||||
|
f'supplier_name_{idx}': supplier_part.supplier.name,
|
||||||
|
f'supplier_sku_{idx}': supplier_part.SKU,
|
||||||
|
f'supplier_mpn_{idx}': manufacturer_part.MPN
|
||||||
|
if manufacturer_part
|
||||||
|
else '',
|
||||||
|
})
|
||||||
|
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
self.n_supplier_cols = max(self.n_supplier_cols, idx)
|
||||||
|
|
||||||
|
return supplier_part_data
|
||||||
|
|
||||||
|
def get_manufacturer_data(self, bom_item: BomItem) -> dict:
|
||||||
|
"""Return manufacturer data for a BomItem."""
|
||||||
|
manufacturer_part_data = {}
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
|
||||||
|
for manufacturer_part in bom_item.sub_part.manufacturer_parts.all():
|
||||||
|
manufacturer_part_data.update({
|
||||||
|
f'manufacturer_name_{idx}': manufacturer_part.manufacturer.name,
|
||||||
|
f'manufacturer_mpn_{idx}': manufacturer_part.MPN,
|
||||||
|
})
|
||||||
|
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
self.n_manufacturer_cols = max(self.n_manufacturer_cols, idx)
|
||||||
|
|
||||||
|
return manufacturer_part_data
|
||||||
|
|
||||||
|
def get_parameter_data(self, bom_item: BomItem) -> dict:
|
||||||
|
"""Return parameter data for a BomItem."""
|
||||||
|
parameter_data = {}
|
||||||
|
|
||||||
|
for parameter in bom_item.sub_part.parameters.all():
|
||||||
|
template = parameter.template
|
||||||
|
if template.pk not in self.parameters:
|
||||||
|
self.parameters[template.pk] = template.name
|
||||||
|
|
||||||
|
parameter_data.update({f'parameter_{template.pk}': parameter.data})
|
||||||
|
|
||||||
|
return parameter_data
|
@ -0,0 +1,21 @@
|
|||||||
|
"""Generic data export plugin for InvenTree."""
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from plugin import InvenTreePlugin
|
||||||
|
from plugin.mixins import DataExportMixin
|
||||||
|
|
||||||
|
|
||||||
|
class InvenTreeExporter(DataExportMixin, InvenTreePlugin):
|
||||||
|
"""Generic exporter plugin for InvenTree."""
|
||||||
|
|
||||||
|
NAME = 'InvenTree Exporter'
|
||||||
|
SLUG = 'inventree-exporter'
|
||||||
|
TITLE = _('InvenTree Generic Exporter')
|
||||||
|
DESCRIPTION = _('Provides support for exporting data from InvenTree')
|
||||||
|
VERSION = '1.0.0'
|
||||||
|
AUTHOR = _('InvenTree contributors')
|
||||||
|
|
||||||
|
def supports_export(self, model_class: type, user, *args, **kwargs) -> bool:
|
||||||
|
"""This exporter supports all model classes."""
|
||||||
|
return True
|
@ -11,10 +11,11 @@ import weasyprint
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
import report.helpers
|
import report.helpers
|
||||||
|
from common.models import DataOutput
|
||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
from plugin import InvenTreePlugin
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import LabelPrintingMixin, SettingsMixin
|
from plugin.mixins import LabelPrintingMixin, SettingsMixin
|
||||||
from report.models import LabelOutput, LabelTemplate
|
from report.models import LabelTemplate
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
@ -76,7 +77,7 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
|
|||||||
PrintingOptionsSerializer = LabelPrintingOptionsSerializer
|
PrintingOptionsSerializer = LabelPrintingOptionsSerializer
|
||||||
|
|
||||||
def print_labels(
|
def print_labels(
|
||||||
self, label: LabelTemplate, output: LabelOutput, items: list, request, **kwargs
|
self, label: LabelTemplate, output: DataOutput, items: list, request, **kwargs
|
||||||
):
|
):
|
||||||
"""Handle printing of the provided labels.
|
"""Handle printing of the provided labels.
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ from plugin.base.icons.mixins import IconPackMixin
|
|||||||
from plugin.base.integration.APICallMixin import APICallMixin
|
from plugin.base.integration.APICallMixin import APICallMixin
|
||||||
from plugin.base.integration.AppMixin import AppMixin
|
from plugin.base.integration.AppMixin import AppMixin
|
||||||
from plugin.base.integration.CurrencyExchangeMixin import CurrencyExchangeMixin
|
from plugin.base.integration.CurrencyExchangeMixin import CurrencyExchangeMixin
|
||||||
|
from plugin.base.integration.DataExport import DataExportMixin
|
||||||
from plugin.base.integration.NavigationMixin import NavigationMixin
|
from plugin.base.integration.NavigationMixin import NavigationMixin
|
||||||
from plugin.base.integration.ReportMixin import ReportMixin
|
from plugin.base.integration.ReportMixin import ReportMixin
|
||||||
from plugin.base.integration.ScheduleMixin import ScheduleMixin
|
from plugin.base.integration.ScheduleMixin import ScheduleMixin
|
||||||
@ -25,6 +26,7 @@ __all__ = [
|
|||||||
'BarcodeMixin',
|
'BarcodeMixin',
|
||||||
'BulkNotificationMethod',
|
'BulkNotificationMethod',
|
||||||
'CurrencyExchangeMixin',
|
'CurrencyExchangeMixin',
|
||||||
|
'DataExportMixin',
|
||||||
'EventMixin',
|
'EventMixin',
|
||||||
'IconPackMixin',
|
'IconPackMixin',
|
||||||
'LabelPrintingMixin',
|
'LabelPrintingMixin',
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
"""Base Class for InvenTree plugins."""
|
"""Base Class for InvenTree plugins."""
|
||||||
|
|
||||||
|
import enum
|
||||||
import inspect
|
import inspect
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from distutils.sysconfig import get_python_lib
|
from distutils.sysconfig import get_python_lib
|
||||||
from importlib.metadata import PackageNotFoundError, metadata
|
from importlib.metadata import PackageNotFoundError, metadata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, Union
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
@ -20,6 +21,36 @@ from plugin.helpers import get_git_log
|
|||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
|
||||||
|
class PluginMixinEnum(str, enum.Enum):
|
||||||
|
"""Enumeration of the available plugin mixin types."""
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return the string representation of the mixin."""
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
BASE = 'base'
|
||||||
|
|
||||||
|
ACTION = 'action'
|
||||||
|
API_CALL = 'api_call'
|
||||||
|
APP = 'app'
|
||||||
|
BARCODE = 'barcode'
|
||||||
|
CURRENCY_EXCHANGE = 'currencyexchange'
|
||||||
|
EVENTS = 'events'
|
||||||
|
EXPORTER = 'exporter'
|
||||||
|
ICON_PACK = 'icon_pack'
|
||||||
|
LABELS = 'labels'
|
||||||
|
LOCATE = 'locate'
|
||||||
|
NAVIGATION = 'navigation'
|
||||||
|
REPORT = 'report'
|
||||||
|
SCHEDULE = 'schedule'
|
||||||
|
SETTINGS = 'settings'
|
||||||
|
SETTINGS_CONTENT = 'settingscontent'
|
||||||
|
SUPPLIER_BARCODE = 'supplier-barcode'
|
||||||
|
URLS = 'urls'
|
||||||
|
USER_INTERFACE = 'ui'
|
||||||
|
VALIDATION = 'validation'
|
||||||
|
|
||||||
|
|
||||||
class MetaBase:
|
class MetaBase:
|
||||||
"""Base class for a plugins metadata."""
|
"""Base class for a plugins metadata."""
|
||||||
|
|
||||||
@ -126,12 +157,15 @@ class MixinBase:
|
|||||||
self._mixins = {}
|
self._mixins = {}
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def mixin(self, key):
|
def mixin(self, key: str) -> bool:
|
||||||
"""Check if mixin is registered."""
|
"""Check if mixin is registered."""
|
||||||
|
key = str(key).lower()
|
||||||
return key in self._mixins
|
return key in self._mixins
|
||||||
|
|
||||||
def mixin_enabled(self, key):
|
def mixin_enabled(self, key: str) -> bool:
|
||||||
"""Check if mixin is registered, enabled and ready."""
|
"""Check if mixin is registered, enabled and ready."""
|
||||||
|
key = str(key).lower()
|
||||||
|
|
||||||
if self.mixin(key):
|
if self.mixin(key):
|
||||||
fnc_name = self._mixins.get(key)
|
fnc_name = self._mixins.get(key)
|
||||||
|
|
||||||
@ -150,6 +184,8 @@ class MixinBase:
|
|||||||
|
|
||||||
def add_mixin(self, key: str, fnc_enabled=True, cls=None):
|
def add_mixin(self, key: str, fnc_enabled=True, cls=None):
|
||||||
"""Add a mixin to the plugins registry."""
|
"""Add a mixin to the plugins registry."""
|
||||||
|
key = str(key).lower()
|
||||||
|
|
||||||
self._mixins[key] = fnc_enabled
|
self._mixins[key] = fnc_enabled
|
||||||
self.setup_mixin(key, cls=cls)
|
self.setup_mixin(key, cls=cls)
|
||||||
|
|
||||||
@ -230,7 +266,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
|||||||
Set paths and load metadata.
|
Set paths and load metadata.
|
||||||
"""
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('base')
|
self.add_mixin(PluginMixinEnum.BASE)
|
||||||
|
|
||||||
self.define_package()
|
self.define_package()
|
||||||
|
|
||||||
@ -351,7 +387,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
|||||||
return self.check_package_path()
|
return self.check_package_path()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def check_package_install_name(cls) -> [str, None]:
|
def check_package_install_name(cls) -> Union[str, None]:
|
||||||
"""Installable package name of the plugin.
|
"""Installable package name of the plugin.
|
||||||
|
|
||||||
e.g. if this plugin was installed via 'pip install <x>',
|
e.g. if this plugin was installed via 'pip install <x>',
|
||||||
@ -363,7 +399,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
|||||||
return getattr(cls, 'package_name', None)
|
return getattr(cls, 'package_name', None)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def package_install_name(self) -> [str, None]:
|
def package_install_name(self) -> Union[str, None]:
|
||||||
"""Installable package name of the plugin.
|
"""Installable package name of the plugin.
|
||||||
|
|
||||||
e.g. if this plugin was installed via 'pip install <x>',
|
e.g. if this plugin was installed via 'pip install <x>',
|
||||||
|
@ -90,8 +90,14 @@ class PluginsRegistry:
|
|||||||
"""Return True if the plugin registry is currently loading."""
|
"""Return True if the plugin registry is currently loading."""
|
||||||
return self.loading_lock.locked()
|
return self.loading_lock.locked()
|
||||||
|
|
||||||
def get_plugin(self, slug, active=None):
|
def get_plugin(self, slug, active=None, with_mixin=None):
|
||||||
"""Lookup plugin by slug (unique key)."""
|
"""Lookup plugin by slug (unique key).
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
slug {str}: The slug (unique key) of the plugin
|
||||||
|
active {bool, None}: Filter by 'active' status of plugin. Defaults to None.
|
||||||
|
with_mixin {str, None}: Filter by mixin. Defaults to None.
|
||||||
|
"""
|
||||||
# Check if the registry needs to be reloaded
|
# Check if the registry needs to be reloaded
|
||||||
self.check_reload()
|
self.check_reload()
|
||||||
|
|
||||||
@ -104,6 +110,9 @@ class PluginsRegistry:
|
|||||||
if active is not None and active != plg.is_active():
|
if active is not None and active != plg.is_active():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if with_mixin is not None and not plg.mixin_enabled(with_mixin):
|
||||||
|
return None
|
||||||
|
|
||||||
return plg
|
return plg
|
||||||
|
|
||||||
def get_plugin_config(self, slug: str, name: Union[str, None] = None):
|
def get_plugin_config(self, slug: str, name: Union[str, None] = None):
|
||||||
@ -191,7 +200,9 @@ class PluginsRegistry:
|
|||||||
return plugin_func(*args, **kwargs)
|
return plugin_func(*args, **kwargs)
|
||||||
|
|
||||||
# region registry functions
|
# region registry functions
|
||||||
def with_mixin(self, mixin: str, active=True, builtin=None):
|
def with_mixin(
|
||||||
|
self, mixin: str, active: bool = True, builtin: Optional[bool] = None
|
||||||
|
) -> list:
|
||||||
"""Returns reference to all plugins that have a specified mixin enabled.
|
"""Returns reference to all plugins that have a specified mixin enabled.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -202,6 +213,8 @@ class PluginsRegistry:
|
|||||||
# Check if the registry needs to be loaded
|
# Check if the registry needs to be loaded
|
||||||
self.check_reload()
|
self.check_reload()
|
||||||
|
|
||||||
|
mixin = str(mixin).lower().strip()
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
|
|
||||||
for plugin in self.plugins.values():
|
for plugin in self.plugins.values():
|
||||||
@ -841,7 +854,7 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
Returns True if the registry has changed and was reloaded.
|
Returns True if the registry has changed and was reloaded.
|
||||||
"""
|
"""
|
||||||
if settings.TESTING:
|
if settings.TESTING and not settings.PLUGIN_TESTING_RELOAD:
|
||||||
# Skip if running during unit testing
|
# Skip if running during unit testing
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -33,7 +33,7 @@ def mixin_enabled(plugin, key, *args, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
def mixin_available(mixin, *args, **kwargs):
|
def mixin_available(mixin: str, *args, **kwargs) -> bool:
|
||||||
"""Returns True if there is at least one active plugin which supports the provided mixin."""
|
"""Returns True if there is at least one active plugin which supports the provided mixin."""
|
||||||
return len(registry.with_mixin(mixin)) > 0
|
return len(registry.with_mixin(mixin)) > 0
|
||||||
|
|
||||||
|
@ -3,14 +3,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .helpers import report_model_options
|
from .helpers import report_model_options
|
||||||
from .models import (
|
from .models import LabelTemplate, ReportAsset, ReportSnippet, ReportTemplate
|
||||||
LabelOutput,
|
|
||||||
LabelTemplate,
|
|
||||||
ReportAsset,
|
|
||||||
ReportOutput,
|
|
||||||
ReportSnippet,
|
|
||||||
ReportTemplate,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LabelTemplate)
|
@admin.register(LabelTemplate)
|
||||||
@ -42,11 +35,3 @@ class ReportAssetAdmin(admin.ModelAdmin):
|
|||||||
"""Admin class for the ReportAsset model."""
|
"""Admin class for the ReportAsset model."""
|
||||||
|
|
||||||
list_display = ('id', 'asset', 'description')
|
list_display = ('id', 'asset', 'description')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LabelOutput)
|
|
||||||
@admin.register(ReportOutput)
|
|
||||||
class TemplateOutputAdmin(admin.ModelAdmin):
|
|
||||||
"""Admin class for the TemplateOutput model."""
|
|
||||||
|
|
||||||
list_display = ('id', 'output', 'progress', 'complete')
|
|
||||||
|
@ -18,9 +18,11 @@ import InvenTree.permissions
|
|||||||
import report.helpers
|
import report.helpers
|
||||||
import report.models
|
import report.models
|
||||||
import report.serializers
|
import report.serializers
|
||||||
from InvenTree.api import BulkDeleteMixin, MetadataView
|
from common.models import DataOutput
|
||||||
from InvenTree.filters import InvenTreeOrderingFilter, InvenTreeSearchFilter
|
from common.serializers import DataOutputSerializer
|
||||||
from InvenTree.mixins import ListAPI, ListCreateAPI, RetrieveUpdateDestroyAPI
|
from InvenTree.api import MetadataView
|
||||||
|
from InvenTree.filters import InvenTreeSearchFilter
|
||||||
|
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||||
from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin
|
from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin
|
||||||
|
|
||||||
|
|
||||||
@ -161,8 +163,7 @@ class LabelPrint(GenericAPIView):
|
|||||||
if plugin_serializer:
|
if plugin_serializer:
|
||||||
kwargs['plugin_serializer'] = plugin_serializer
|
kwargs['plugin_serializer'] = plugin_serializer
|
||||||
|
|
||||||
serializer = super().get_serializer(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
return serializer
|
|
||||||
|
|
||||||
@method_decorator(never_cache)
|
@method_decorator(never_cache)
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
@ -202,14 +203,17 @@ class LabelPrint(GenericAPIView):
|
|||||||
):
|
):
|
||||||
plugin_serializer.is_valid(raise_exception=True)
|
plugin_serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
# Generate a new LabelOutput object to print against
|
user = getattr(request, 'user', None)
|
||||||
output = report.models.LabelOutput.objects.create(
|
|
||||||
template=template,
|
# Generate a new DataOutput object to print against
|
||||||
plugin=plugin.slug,
|
output = DataOutput.objects.create(
|
||||||
user=request.user,
|
user=user if user and user.is_authenticated else None,
|
||||||
|
total=len(items_to_print),
|
||||||
progress=0,
|
progress=0,
|
||||||
items=len(items_to_print),
|
|
||||||
complete=False,
|
complete=False,
|
||||||
|
output_type=DataOutput.DataOutputTypes.LABEL,
|
||||||
|
plugin=plugin.slug,
|
||||||
|
template_name=template.name,
|
||||||
output=None,
|
output=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -224,9 +228,7 @@ class LabelPrint(GenericAPIView):
|
|||||||
|
|
||||||
output.refresh_from_db()
|
output.refresh_from_db()
|
||||||
|
|
||||||
return Response(
|
return Response(DataOutputSerializer(output).data, status=201)
|
||||||
report.serializers.LabelOutputSerializer(output).data, status=201
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LabelTemplateList(TemplatePermissionMixin, ListCreateAPI):
|
class LabelTemplateList(TemplatePermissionMixin, ListCreateAPI):
|
||||||
@ -274,18 +276,21 @@ class ReportPrint(GenericAPIView):
|
|||||||
"""Print this report template against a number of provided items.
|
"""Print this report template against a number of provided items.
|
||||||
|
|
||||||
This functionality is offloaded to the background worker process,
|
This functionality is offloaded to the background worker process,
|
||||||
which will update the status of the ReportOutput object as it progresses.
|
which will update the status of the DataOutput object as it progresses.
|
||||||
"""
|
"""
|
||||||
import report.tasks
|
import report.tasks
|
||||||
from InvenTree.tasks import offload_task
|
from InvenTree.tasks import offload_task
|
||||||
|
|
||||||
# Generate a new ReportOutput object
|
user = getattr(request, 'user', None)
|
||||||
output = report.models.ReportOutput.objects.create(
|
|
||||||
template=template,
|
# Generate a new DataOutput object
|
||||||
user=request.user,
|
output = DataOutput.objects.create(
|
||||||
|
user=user if user and user.is_authenticated else None,
|
||||||
|
total=len(items_to_print),
|
||||||
progress=0,
|
progress=0,
|
||||||
items=len(items_to_print),
|
|
||||||
complete=False,
|
complete=False,
|
||||||
|
output_type=DataOutput.DataOutputTypes.REPORT,
|
||||||
|
template_name=template.name,
|
||||||
output=None,
|
output=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -296,9 +301,7 @@ class ReportPrint(GenericAPIView):
|
|||||||
|
|
||||||
output.refresh_from_db()
|
output.refresh_from_db()
|
||||||
|
|
||||||
return Response(
|
return Response(DataOutputSerializer(output).data, status=201)
|
||||||
report.serializers.ReportOutputSerializer(output).data, status=201
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ReportTemplateList(TemplatePermissionMixin, ListCreateAPI):
|
class ReportTemplateList(TemplatePermissionMixin, ListCreateAPI):
|
||||||
@ -347,44 +350,6 @@ class ReportAssetDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI):
|
|||||||
serializer_class = report.serializers.ReportAssetSerializer
|
serializer_class = report.serializers.ReportAssetSerializer
|
||||||
|
|
||||||
|
|
||||||
class TemplateOutputMixin:
|
|
||||||
"""Mixin class for template output API endpoints."""
|
|
||||||
|
|
||||||
filter_backends = [InvenTreeOrderingFilter]
|
|
||||||
ordering_fields = ['created', 'model_type', 'user']
|
|
||||||
ordering_field_aliases = {'model_type': 'template__model_type'}
|
|
||||||
|
|
||||||
|
|
||||||
class LabelOutputMixin(TemplatePermissionMixin, TemplateOutputMixin):
|
|
||||||
"""Mixin class for a label output API endpoint."""
|
|
||||||
|
|
||||||
queryset = report.models.LabelOutput.objects.all()
|
|
||||||
serializer_class = report.serializers.LabelOutputSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class LabelOutputList(LabelOutputMixin, BulkDeleteMixin, ListAPI):
|
|
||||||
"""List endpoint for LabelOutput objects."""
|
|
||||||
|
|
||||||
|
|
||||||
class LabelOutputDetail(LabelOutputMixin, RetrieveUpdateDestroyAPI):
|
|
||||||
"""Detail endpoint for LabelOutput objects."""
|
|
||||||
|
|
||||||
|
|
||||||
class ReportOutputMixin(TemplatePermissionMixin, TemplateOutputMixin):
|
|
||||||
"""Mixin class for a report output API endpoint."""
|
|
||||||
|
|
||||||
queryset = report.models.ReportOutput.objects.all()
|
|
||||||
serializer_class = report.serializers.ReportOutputSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class ReportOutputList(ReportOutputMixin, BulkDeleteMixin, ListAPI):
|
|
||||||
"""List endpoint for ReportOutput objects."""
|
|
||||||
|
|
||||||
|
|
||||||
class ReportOutputDetail(ReportOutputMixin, RetrieveUpdateDestroyAPI):
|
|
||||||
"""Detail endpoint for ReportOutput objects."""
|
|
||||||
|
|
||||||
|
|
||||||
label_api_urls = [
|
label_api_urls = [
|
||||||
# Printing endpoint
|
# Printing endpoint
|
||||||
path('print/', LabelPrint.as_view(), name='api-label-print'),
|
path('print/', LabelPrint.as_view(), name='api-label-print'),
|
||||||
@ -411,16 +376,6 @@ label_api_urls = [
|
|||||||
path('', LabelTemplateList.as_view(), name='api-label-template-list'),
|
path('', LabelTemplateList.as_view(), name='api-label-template-list'),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
# Label outputs
|
|
||||||
path(
|
|
||||||
'output/',
|
|
||||||
include([
|
|
||||||
path(
|
|
||||||
'<int:pk>/', LabelOutputDetail.as_view(), name='api-label-output-detail'
|
|
||||||
),
|
|
||||||
path('', LabelOutputList.as_view(), name='api-label-output-list'),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
report_api_urls = [
|
report_api_urls = [
|
||||||
@ -449,18 +404,6 @@ report_api_urls = [
|
|||||||
path('', ReportTemplateList.as_view(), name='api-report-template-list'),
|
path('', ReportTemplateList.as_view(), name='api-report-template-list'),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
# Generated report outputs
|
|
||||||
path(
|
|
||||||
'output/',
|
|
||||||
include([
|
|
||||||
path(
|
|
||||||
'<int:pk>/',
|
|
||||||
ReportOutputDetail.as_view(),
|
|
||||||
name='api-report-output-detail',
|
|
||||||
),
|
|
||||||
path('', ReportOutputList.as_view(), name='api-report-output-list'),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
# Report assets
|
# Report assets
|
||||||
path(
|
path(
|
||||||
'asset/',
|
'asset/',
|
||||||
|
@ -36,6 +36,8 @@ class ReportConfig(AppConfig):
|
|||||||
|
|
||||||
super().ready()
|
super().ready()
|
||||||
|
|
||||||
|
self.cleanup()
|
||||||
|
|
||||||
# skip loading if plugin registry is not loaded or we run in a background thread
|
# skip loading if plugin registry is not loaded or we run in a background thread
|
||||||
if (
|
if (
|
||||||
not InvenTree.ready.isPluginRegistryLoaded()
|
not InvenTree.ready.isPluginRegistryLoaded()
|
||||||
@ -62,6 +64,15 @@ class ReportConfig(AppConfig):
|
|||||||
|
|
||||||
set_maintenance_mode(False)
|
set_maintenance_mode(False)
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Cleanup old label and report outputs."""
|
||||||
|
try:
|
||||||
|
from report.tasks import cleanup_old_report_outputs
|
||||||
|
|
||||||
|
cleanup_old_report_outputs()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def file_from_template(self, dir_name: str, file_name: str) -> ContentFile:
|
def file_from_template(self, dir_name: str, file_name: str) -> ContentFile:
|
||||||
"""Construct a new ContentFile from a template file."""
|
"""Construct a new ContentFile from a template file."""
|
||||||
logger.info('Creating %s template file: %s', dir_name, file_name)
|
logger.info('Creating %s template file: %s', dir_name, file_name)
|
||||||
|
@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-03-07 01:45
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("report", "0028_labeltemplate_attach_to_model_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="reportoutput",
|
||||||
|
name="template",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="reportoutput",
|
||||||
|
name="user",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="LabelOutput",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="ReportOutput",
|
||||||
|
),
|
||||||
|
]
|
@ -5,7 +5,6 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
@ -25,10 +24,11 @@ import InvenTree.helpers
|
|||||||
import InvenTree.models
|
import InvenTree.models
|
||||||
import report.helpers
|
import report.helpers
|
||||||
import report.validators
|
import report.validators
|
||||||
|
from common.models import DataOutput
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
from InvenTree.helpers_model import get_base_url
|
from InvenTree.helpers_model import get_base_url
|
||||||
from InvenTree.models import MetadataMixin
|
from InvenTree.models import MetadataMixin
|
||||||
from plugin import InvenTreePlugin
|
from plugin import InvenTreePlugin, PluginMixinEnum
|
||||||
from plugin.registry import registry
|
from plugin.registry import registry
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -338,7 +338,7 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Pass the context through to the plugin registry for any additional information
|
# Pass the context through to the plugin registry for any additional information
|
||||||
for plugin in registry.with_mixin('report'):
|
for plugin in registry.with_mixin(PluginMixinEnum.REPORT):
|
||||||
try:
|
try:
|
||||||
plugin.add_report_context(self, instance, request, context)
|
plugin.add_report_context(self, instance, request, context)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -348,16 +348,16 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
|||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def print(self, items: list, request=None, output=None, **kwargs) -> 'ReportOutput':
|
def print(self, items: list, request=None, output=None, **kwargs) -> DataOutput:
|
||||||
"""Print reports for a list of items against this template.
|
"""Print reports for a list of items against this template.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
items: A list of items to print reports for (model instance)
|
items: A list of items to print reports for (model instance)
|
||||||
output: The ReportOutput object to use (if provided)
|
output: The DataOutput object to use (if provided)
|
||||||
request: The request object (optional)
|
request: The request object (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
output: The ReportOutput object representing the generated report(s)
|
output: The DataOutput object representing the generated report(s)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: If there is an error during report printing
|
ValidationError: If there is an error during report printing
|
||||||
@ -369,8 +369,6 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
|||||||
Further work is required to allow the following extended features:
|
Further work is required to allow the following extended features:
|
||||||
- Render a single PDF file with the collated items (optional per template)
|
- Render a single PDF file with the collated items (optional per template)
|
||||||
- Render a raw file (do not convert to PDF) - allows for other file types
|
- Render a raw file (do not convert to PDF) - allows for other file types
|
||||||
- Render using background worker, provide progress updates to UI
|
|
||||||
- Run report generation in the background worker process
|
|
||||||
"""
|
"""
|
||||||
logger.info("Printing %s reports against template '%s'", len(items), self.name)
|
logger.info("Printing %s reports against template '%s'", len(items), self.name)
|
||||||
|
|
||||||
@ -381,18 +379,19 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
|||||||
# Start with a default report name
|
# Start with a default report name
|
||||||
report_name = None
|
report_name = None
|
||||||
|
|
||||||
report_plugins = registry.with_mixin('report')
|
report_plugins = registry.with_mixin(PluginMixinEnum.REPORT)
|
||||||
|
|
||||||
# If a ReportOutput object is not provided, create a new one
|
# If a DataOutput object is not provided, create a new one
|
||||||
if not output:
|
if not output:
|
||||||
output = ReportOutput.objects.create(
|
output = DataOutput.objects.create(
|
||||||
template=self,
|
total=len(items),
|
||||||
items=len(items),
|
|
||||||
user=request.user
|
user=request.user
|
||||||
if request and request.user and request.user.is_authenticated
|
if request and request.user and request.user.is_authenticated
|
||||||
else None,
|
else None,
|
||||||
progress=0,
|
progress=0,
|
||||||
complete=False,
|
complete=False,
|
||||||
|
output_type=DataOutput.DataOutputTypes.REPORT,
|
||||||
|
template_name=self.name,
|
||||||
output=None,
|
output=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -547,7 +546,7 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
|
|||||||
context['page_style'] = self.generate_page_style()
|
context['page_style'] = self.generate_page_style()
|
||||||
|
|
||||||
# Pass the context through to any registered plugins
|
# Pass the context through to any registered plugins
|
||||||
plugins = registry.with_mixin('report')
|
plugins = registry.with_mixin(PluginMixinEnum.REPORT)
|
||||||
|
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
# Let each plugin add its own context data
|
# Let each plugin add its own context data
|
||||||
@ -568,18 +567,18 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
|
|||||||
options=None,
|
options=None,
|
||||||
request=None,
|
request=None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> 'LabelOutput':
|
) -> DataOutput:
|
||||||
"""Print labels for a list of items against this template.
|
"""Print labels for a list of items against this template.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
items: A list of items to print labels for (model instance)
|
items: A list of items to print labels for (model instance)
|
||||||
plugin: The plugin to use for label rendering
|
plugin: The plugin to use for label rendering
|
||||||
output: The LabelOutput object to use (if provided)
|
output: The DataOutput object to use (if provided)
|
||||||
options: Additional options for the label printing plugin (optional)
|
options: Additional options for the label printing plugin (optional)
|
||||||
request: The request object (optional)
|
request: The request object (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
output: The LabelOutput object representing the generated label(s)
|
output: The DataOutput object representing the generated label(s)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: If there is an error during label printing
|
ValidationError: If there is an error during label printing
|
||||||
@ -592,15 +591,17 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not output:
|
if not output:
|
||||||
output = LabelOutput.objects.create(
|
output = DataOutput.objects.create(
|
||||||
template=self,
|
|
||||||
items=len(items),
|
|
||||||
plugin=plugin.slug,
|
|
||||||
user=request.user
|
user=request.user
|
||||||
if request and request.user.is_authenticated
|
if request and request.user.is_authenticated
|
||||||
else None,
|
else None,
|
||||||
|
total=len(items),
|
||||||
progress=0,
|
progress=0,
|
||||||
complete=False,
|
complete=False,
|
||||||
|
output_type=DataOutput.DataOutputTypes.LABEL,
|
||||||
|
template_name=self.name,
|
||||||
|
plugin=plugin.slug,
|
||||||
|
output=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
if options is None:
|
if options is None:
|
||||||
@ -628,81 +629,6 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
class TemplateOutput(models.Model):
|
|
||||||
"""Base class representing a generated file from a template.
|
|
||||||
|
|
||||||
As reports (or labels) may take a long time to render,
|
|
||||||
this process is offloaded to the background worker process.
|
|
||||||
|
|
||||||
The result is either a file made available for download,
|
|
||||||
or a message indicating that the output is handled externally.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""Metaclass options."""
|
|
||||||
|
|
||||||
abstract = True
|
|
||||||
|
|
||||||
created = models.DateField(auto_now_add=True, editable=False)
|
|
||||||
|
|
||||||
user = models.ForeignKey(
|
|
||||||
User, on_delete=models.SET_NULL, blank=True, null=True, related_name='+'
|
|
||||||
)
|
|
||||||
|
|
||||||
items = models.PositiveIntegerField(
|
|
||||||
default=0, verbose_name=_('Items'), help_text=_('Number of items to process')
|
|
||||||
)
|
|
||||||
|
|
||||||
complete = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name=_('Complete'),
|
|
||||||
help_text=_('Report generation is complete'),
|
|
||||||
)
|
|
||||||
|
|
||||||
progress = models.PositiveIntegerField(
|
|
||||||
default=0, verbose_name=_('Progress'), help_text=_('Report generation progress')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ReportOutput(TemplateOutput):
|
|
||||||
"""Class representing a generated report output file."""
|
|
||||||
|
|
||||||
template = models.ForeignKey(
|
|
||||||
ReportTemplate, on_delete=models.CASCADE, verbose_name=_('Report Template')
|
|
||||||
)
|
|
||||||
|
|
||||||
output = models.FileField(
|
|
||||||
upload_to='report/output',
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name=_('Output File'),
|
|
||||||
help_text=_('Generated output file'),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LabelOutput(TemplateOutput):
|
|
||||||
"""Class representing a generated label output file."""
|
|
||||||
|
|
||||||
plugin = models.CharField(
|
|
||||||
max_length=100,
|
|
||||||
blank=True,
|
|
||||||
verbose_name=_('Plugin'),
|
|
||||||
help_text=_('Label output plugin'),
|
|
||||||
)
|
|
||||||
|
|
||||||
template = models.ForeignKey(
|
|
||||||
LabelTemplate, on_delete=models.CASCADE, verbose_name=_('Label Template')
|
|
||||||
)
|
|
||||||
|
|
||||||
output = models.FileField(
|
|
||||||
upload_to='label/output',
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name=_('Output File'),
|
|
||||||
help_text=_('Generated output file'),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ReportSnippet(TemplateUploadMixin, models.Model):
|
class ReportSnippet(TemplateUploadMixin, models.Model):
|
||||||
"""Report template 'snippet' which can be used to make templates that can then be included in other reports.
|
"""Report template 'snippet' which can be used to make templates that can then be included in other reports.
|
||||||
|
|
||||||
|
@ -12,7 +12,6 @@ from InvenTree.serializers import (
|
|||||||
InvenTreeAttachmentSerializerField,
|
InvenTreeAttachmentSerializerField,
|
||||||
InvenTreeModelSerializer,
|
InvenTreeModelSerializer,
|
||||||
)
|
)
|
||||||
from users.serializers import UserSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class ReportSerializerBase(InvenTreeModelSerializer):
|
class ReportSerializerBase(InvenTreeModelSerializer):
|
||||||
@ -161,59 +160,6 @@ class LabelTemplateSerializer(ReportSerializerBase):
|
|||||||
fields = [*ReportSerializerBase.base_fields(), 'width', 'height']
|
fields = [*ReportSerializerBase.base_fields(), 'width', 'height']
|
||||||
|
|
||||||
|
|
||||||
class BaseOutputSerializer(InvenTreeModelSerializer):
|
|
||||||
"""Base serializer class for template output."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def base_fields():
|
|
||||||
"""Basic field set."""
|
|
||||||
return [
|
|
||||||
'pk',
|
|
||||||
'created',
|
|
||||||
'user',
|
|
||||||
'user_detail',
|
|
||||||
'model_type',
|
|
||||||
'items',
|
|
||||||
'complete',
|
|
||||||
'progress',
|
|
||||||
'output',
|
|
||||||
'template',
|
|
||||||
]
|
|
||||||
|
|
||||||
output = InvenTreeAttachmentSerializerField()
|
|
||||||
model_type = serializers.CharField(source='template.model_type', read_only=True)
|
|
||||||
|
|
||||||
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
|
||||||
|
|
||||||
|
|
||||||
class LabelOutputSerializer(BaseOutputSerializer):
|
|
||||||
"""Serializer class for the LabelOutput model."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""Metaclass options."""
|
|
||||||
|
|
||||||
model = report.models.LabelOutput
|
|
||||||
fields = [*BaseOutputSerializer.base_fields(), 'plugin', 'template_detail']
|
|
||||||
|
|
||||||
template_detail = LabelTemplateSerializer(
|
|
||||||
source='template', many=False, read_only=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ReportOutputSerializer(BaseOutputSerializer):
|
|
||||||
"""Serializer class for the ReportOutput model."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""Metaclass options."""
|
|
||||||
|
|
||||||
model = report.models.ReportOutput
|
|
||||||
fields = [*BaseOutputSerializer.base_fields(), 'template_detail']
|
|
||||||
|
|
||||||
template_detail = ReportTemplateSerializer(
|
|
||||||
source='template', many=False, read_only=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ReportSnippetSerializer(InvenTreeModelSerializer):
|
class ReportSnippetSerializer(InvenTreeModelSerializer):
|
||||||
"""Serializer class for the ReportSnippet model."""
|
"""Serializer class for the ReportSnippet model."""
|
||||||
|
|
||||||
|
@ -1,43 +1,29 @@
|
|||||||
"""Background tasks for the report app."""
|
"""Background tasks for the report app."""
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from InvenTree.exceptions import log_error
|
from InvenTree.exceptions import log_error
|
||||||
from InvenTree.helpers import current_time
|
|
||||||
from InvenTree.tasks import ScheduledTask, scheduled_task
|
|
||||||
from report.models import LabelOutput, ReportOutput
|
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
|
||||||
@scheduled_task(ScheduledTask.DAILY)
|
|
||||||
def cleanup_old_report_outputs():
|
|
||||||
"""Remove old report/label outputs from the database."""
|
|
||||||
# Remove any outputs which are older than 5 days
|
|
||||||
threshold = current_time() - timedelta(days=5)
|
|
||||||
|
|
||||||
LabelOutput.objects.filter(created__lte=threshold).delete()
|
|
||||||
ReportOutput.objects.filter(created__lte=threshold).delete()
|
|
||||||
|
|
||||||
|
|
||||||
def print_reports(template_id: int, item_ids: list[int], output_id: int, **kwargs):
|
def print_reports(template_id: int, item_ids: list[int], output_id: int, **kwargs):
|
||||||
"""Print multiple reports against the provided template.
|
"""Print multiple reports against the provided template.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
template_id: The ID of the ReportTemplate to use
|
template_id: The ID of the ReportTemplate to use
|
||||||
item_ids: List of item IDs to generate the report against
|
item_ids: List of item IDs to generate the report against
|
||||||
output_id: The ID of the ReportOutput to use (if provided)
|
output_id: The ID of the DataOutput to use (if provided)
|
||||||
|
|
||||||
This function is intended to be called by the background worker,
|
This function is intended to be called by the background worker,
|
||||||
and will continuously update the status of the ReportOutput object.
|
and will continuously update the status of the DataOutput object.
|
||||||
"""
|
"""
|
||||||
from report.models import ReportOutput, ReportTemplate
|
from common.models import DataOutput
|
||||||
|
from report.models import ReportTemplate
|
||||||
|
|
||||||
try:
|
try:
|
||||||
template = ReportTemplate.objects.get(pk=template_id)
|
template = ReportTemplate.objects.get(pk=template_id)
|
||||||
output = ReportOutput.objects.get(pk=output_id)
|
output = DataOutput.objects.get(pk=output_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
log_error('report.tasks.print_reports')
|
log_error('report.tasks.print_reports')
|
||||||
return
|
return
|
||||||
@ -57,18 +43,19 @@ def print_labels(
|
|||||||
Arguments:
|
Arguments:
|
||||||
template_id: The ID of the LabelTemplate to use
|
template_id: The ID of the LabelTemplate to use
|
||||||
item_ids: List of item IDs to generate the labels against
|
item_ids: List of item IDs to generate the labels against
|
||||||
output_id: The ID of the LabelOutput to use (if provided)
|
output_id: The ID of the DataOutput to use (if provided)
|
||||||
plugin_slug: The ID of the LabelPlugin to use (if provided)
|
plugin_slug: The ID of the LabelPlugin to use (if provided)
|
||||||
|
|
||||||
This function is intended to be called by the background worker,
|
This function is intended to be called by the background worker,
|
||||||
and will continuously update the status of the LabelOutput object.
|
and will continuously update the status of the DataOutput object.
|
||||||
"""
|
"""
|
||||||
|
from common.models import DataOutput
|
||||||
from plugin.registry import registry
|
from plugin.registry import registry
|
||||||
from report.models import LabelOutput, LabelTemplate
|
from report.models import LabelTemplate
|
||||||
|
|
||||||
try:
|
try:
|
||||||
template = LabelTemplate.objects.get(pk=template_id)
|
template = LabelTemplate.objects.get(pk=template_id)
|
||||||
output = LabelOutput.objects.get(pk=output_id)
|
output = DataOutput.objects.get(pk=output_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
log_error('report.tasks.print_labels')
|
log_error('report.tasks.print_labels')
|
||||||
return
|
return
|
||||||
|
@ -290,7 +290,7 @@ class ReportTest(InvenTreeAPITestCase):
|
|||||||
output = template.print(items)
|
output = template.print(items)
|
||||||
|
|
||||||
self.assertTrue(output.complete)
|
self.assertTrue(output.complete)
|
||||||
self.assertEqual(output.items, 5)
|
self.assertEqual(output.total, 5)
|
||||||
self.assertIsNotNone(output.output)
|
self.assertIsNotNone(output.output)
|
||||||
self.assertTrue(output.output.name.endswith('.pdf'))
|
self.assertTrue(output.output.name.endswith('.pdf'))
|
||||||
|
|
||||||
@ -341,8 +341,9 @@ class LabelTest(InvenTreeAPITestCase):
|
|||||||
output = template.print(items=parts, plugin=plugin)
|
output = template.print(items=parts, plugin=plugin)
|
||||||
|
|
||||||
self.assertTrue(output.complete)
|
self.assertTrue(output.complete)
|
||||||
self.assertEqual(output.items, 10)
|
self.assertEqual(output.total, 10)
|
||||||
self.assertIsNotNone(output.output)
|
self.assertIsNotNone(output.output)
|
||||||
|
self.assertEqual(output.plugin, 'inventreelabel')
|
||||||
self.assertTrue(output.output.name.endswith('.pdf'))
|
self.assertTrue(output.output.name.endswith('.pdf'))
|
||||||
|
|
||||||
|
|
||||||
@ -461,7 +462,7 @@ class TestReportTest(PrintTestMixins, ReportTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# There should be a link to the generated PDF
|
# There should be a link to the generated PDF
|
||||||
self.assertTrue(response.data['output'].startswith('/media/report/'))
|
self.assertTrue(response.data['output'].startswith('/media/data_output/'))
|
||||||
self.assertTrue(response.data['output'].endswith('.pdf'))
|
self.assertTrue(response.data['output'].endswith('.pdf'))
|
||||||
|
|
||||||
# By default, this should *not* have created an attachment against this stockitem
|
# By default, this should *not* have created an attachment against this stockitem
|
||||||
|
@ -25,8 +25,8 @@ from build.models import Build
|
|||||||
from build.serializers import BuildSerializer
|
from build.serializers import BuildSerializer
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
from company.serializers import CompanySerializer
|
from company.serializers import CompanySerializer
|
||||||
|
from data_exporter.mixins import DataExportViewMixin
|
||||||
from generic.states.api import StatusView
|
from generic.states.api import StatusView
|
||||||
from importer.mixins import DataExportViewMixin
|
|
||||||
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
|
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
|
||||||
from InvenTree.filters import (
|
from InvenTree.filters import (
|
||||||
ORDER_FILTER_ALIAS,
|
ORDER_FILTER_ALIAS,
|
||||||
@ -356,7 +356,7 @@ class StockLocationMixin:
|
|||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
"""Return annotated queryset for the StockLocationList endpoint."""
|
"""Return annotated queryset for the StockLocationList endpoint."""
|
||||||
@ -952,7 +952,7 @@ class StockApiMixin:
|
|||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView):
|
class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView):
|
||||||
@ -1253,7 +1253,7 @@ class StockItemTestResultMixin:
|
|||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class StockItemTestResultDetail(StockItemTestResultMixin, RetrieveUpdateDestroyAPI):
|
class StockItemTestResultDetail(StockItemTestResultMixin, RetrieveUpdateDestroyAPI):
|
||||||
@ -1394,7 +1394,7 @@ class StockTrackingList(DataExportViewMixin, ListAPI):
|
|||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
def get_delta_model_map(self) -> dict:
|
def get_delta_model_map(self) -> dict:
|
||||||
"""Return a mapping of delta models to their respective models and serializers.
|
"""Return a mapping of delta models to their respective models and serializers.
|
||||||
|
@ -23,7 +23,7 @@ def generate_batch_code(**kwargs):
|
|||||||
Various kwargs can be passed to the function, which will be passed through to the plugin functions.
|
Various kwargs can be passed to the function, which will be passed through to the plugin functions.
|
||||||
"""
|
"""
|
||||||
# First, check if any plugins can generate batch codes
|
# First, check if any plugins can generate batch codes
|
||||||
from plugin.registry import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
now = InvenTree.helpers.current_time()
|
now = InvenTree.helpers.current_time()
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ def generate_batch_code(**kwargs):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
}
|
}
|
||||||
|
|
||||||
for plugin in registry.with_mixin('validation'):
|
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||||
generate = getattr(plugin, 'generate_batch_code', None)
|
generate = getattr(plugin, 'generate_batch_code', None)
|
||||||
|
|
||||||
if not generate:
|
if not generate:
|
||||||
|
@ -530,12 +530,12 @@ class StockItem(
|
|||||||
|
|
||||||
This function hooks into the plugin system to allow for custom serial number conversion.
|
This function hooks into the plugin system to allow for custom serial number conversion.
|
||||||
"""
|
"""
|
||||||
from plugin.registry import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
# First, let any plugins convert this serial number to an integer value
|
# First, let any plugins convert this serial number to an integer value
|
||||||
# If a non-null value is returned (by any plugin) we will use that
|
# If a non-null value is returned (by any plugin) we will use that
|
||||||
|
|
||||||
for plugin in registry.with_mixin('validation'):
|
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||||
try:
|
try:
|
||||||
serial_int = plugin.convert_serial_to_int(serial)
|
serial_int = plugin.convert_serial_to_int(serial)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -721,9 +721,9 @@ class StockItem(
|
|||||||
- Validation is performed by custom plugins.
|
- Validation is performed by custom plugins.
|
||||||
- By default, no validation checks are performed
|
- By default, no validation checks are performed
|
||||||
"""
|
"""
|
||||||
from plugin.registry import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
for plugin in registry.with_mixin('validation'):
|
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
|
||||||
try:
|
try:
|
||||||
plugin.validate_batch_code(self.batch, self)
|
plugin.validate_batch_code(self.batch, self)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
|
@ -28,8 +28,8 @@ import stock.filters
|
|||||||
import stock.status_codes
|
import stock.status_codes
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
||||||
from importer.mixins import DataImportExportSerializerMixin
|
|
||||||
from importer.registry import register_importer
|
from importer.registry import register_importer
|
||||||
|
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||||
from InvenTree.ready import isGeneratingSchema
|
from InvenTree.ready import isGeneratingSchema
|
||||||
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
|
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
|
||||||
from users.serializers import UserSerializer
|
from users.serializers import UserSerializer
|
||||||
|
@ -1,17 +1,14 @@
|
|||||||
"""Unit testing for the Stock API."""
|
"""Unit testing for the Stock API."""
|
||||||
|
|
||||||
import io
|
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
|
||||||
import django.http
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
import tablib
|
|
||||||
from djmoney.money import Money
|
from djmoney.money import Money
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
@ -839,34 +836,9 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(response['results']), n)
|
self.assertEqual(len(response['results']), n)
|
||||||
|
|
||||||
def export_data(self, filters=None):
|
|
||||||
"""Helper to test exports."""
|
|
||||||
if not filters:
|
|
||||||
filters = {}
|
|
||||||
|
|
||||||
filters['export'] = 'csv'
|
|
||||||
|
|
||||||
response = self.client.get(self.list_url, data=filters)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
self.assertIsInstance(response, django.http.response.StreamingHttpResponse)
|
|
||||||
|
|
||||||
file_object = io.StringIO(response.getvalue().decode('utf-8'))
|
|
||||||
|
|
||||||
dataset = tablib.Dataset().load(file_object, 'csv', headers=True)
|
|
||||||
|
|
||||||
return dataset
|
|
||||||
|
|
||||||
def test_export(self):
|
def test_export(self):
|
||||||
"""Test exporting of Stock data via the API."""
|
"""Test exporting of Stock data via the API."""
|
||||||
dataset = self.export_data({})
|
required_headers = [
|
||||||
|
|
||||||
# Check that *all* stock item objects have been exported
|
|
||||||
self.assertEqual(len(dataset), StockItem.objects.count())
|
|
||||||
|
|
||||||
# Expected headers
|
|
||||||
headers = [
|
|
||||||
'Part',
|
'Part',
|
||||||
'Customer',
|
'Customer',
|
||||||
'Stock Location',
|
'Stock Location',
|
||||||
@ -881,45 +853,40 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
'Supplier Part.MPN',
|
'Supplier Part.MPN',
|
||||||
]
|
]
|
||||||
|
|
||||||
for h in headers:
|
|
||||||
self.assertIn(h, dataset.headers)
|
|
||||||
|
|
||||||
excluded_headers = ['metadata']
|
excluded_headers = ['metadata']
|
||||||
|
|
||||||
for h in excluded_headers:
|
with self.export_data(self.list_url) as data_file:
|
||||||
self.assertNotIn(h, dataset.headers)
|
self.process_csv(
|
||||||
|
data_file,
|
||||||
|
required_cols=required_headers,
|
||||||
|
excluded_cols=excluded_headers,
|
||||||
|
required_rows=StockItem.objects.count(),
|
||||||
|
)
|
||||||
|
|
||||||
# Now, add a filter to the results
|
# Now, add a filter to the results
|
||||||
dataset = self.export_data({'location': 1, 'cascade': True})
|
with self.export_data(
|
||||||
|
self.list_url, {'location': 1, 'cascade': True}
|
||||||
|
) as data_file:
|
||||||
|
data = self.process_csv(data_file, required_rows=9)
|
||||||
|
|
||||||
self.assertEqual(len(dataset), 9)
|
for row in data:
|
||||||
|
item_id = int(row['ID'])
|
||||||
|
loc_id = int(row['Stock Location'])
|
||||||
|
|
||||||
# Read out the data
|
|
||||||
idx_id = dataset.headers.index('ID')
|
|
||||||
idx_loc = dataset.headers.index('Stock Location')
|
|
||||||
idx_loc_name = dataset.headers.index('Location.Name')
|
|
||||||
idx_part_name = dataset.headers.index('Part.Name')
|
|
||||||
|
|
||||||
for row in dataset:
|
|
||||||
item_id = int(row[idx_id])
|
|
||||||
item = StockItem.objects.get(pk=item_id)
|
item = StockItem.objects.get(pk=item_id)
|
||||||
|
|
||||||
loc_id = int(row[idx_loc])
|
|
||||||
|
|
||||||
# Location should match ID
|
# Location should match ID
|
||||||
self.assertEqual(int(loc_id), item.location.pk)
|
self.assertEqual(loc_id, item.location.pk)
|
||||||
|
|
||||||
# Location name should match
|
# Location name should match
|
||||||
loc_name = row[idx_loc_name]
|
self.assertEqual(row['Location.Name'], item.location.name)
|
||||||
self.assertEqual(loc_name, item.location.name)
|
|
||||||
|
|
||||||
# Part name should match
|
# Part name should match
|
||||||
part_name = row[idx_part_name]
|
self.assertEqual(row['Part.Name'], item.part.name)
|
||||||
self.assertEqual(part_name, item.part.name)
|
|
||||||
|
|
||||||
# Export stock items with a specific part
|
# Export stock items with a specific part
|
||||||
dataset = self.export_data({'part': 25})
|
with self.export_data(self.list_url, {'part': 25}) as data_file:
|
||||||
self.assertEqual(len(dataset), 17)
|
self.process_csv(data_file, required_rows=17)
|
||||||
|
|
||||||
def test_filter_by_allocated(self):
|
def test_filter_by_allocated(self):
|
||||||
"""Test that we can filter by "allocated" status.
|
"""Test that we can filter by "allocated" status.
|
||||||
|
@ -190,7 +190,8 @@ class GroupMixin:
|
|||||||
params.get('permission_detail', None)
|
params.get('permission_detail', None)
|
||||||
)
|
)
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
return self.serializer_class(*args, **kwargs)
|
|
||||||
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class GroupDetail(GroupMixin, RetrieveUpdateDestroyAPI):
|
class GroupDetail(GroupMixin, RetrieveUpdateDestroyAPI):
|
||||||
|
@ -222,10 +222,8 @@ class RuleSet(models.Model):
|
|||||||
'auth_permission',
|
'auth_permission',
|
||||||
'users_apitoken',
|
'users_apitoken',
|
||||||
'users_ruleset',
|
'users_ruleset',
|
||||||
'report_labeloutput',
|
|
||||||
'report_labeltemplate',
|
'report_labeltemplate',
|
||||||
'report_reportasset',
|
'report_reportasset',
|
||||||
'report_reportoutput',
|
|
||||||
'report_reportsnippet',
|
'report_reportsnippet',
|
||||||
'report_reporttemplate',
|
'report_reporttemplate',
|
||||||
'account_emailaddress',
|
'account_emailaddress',
|
||||||
@ -336,6 +334,7 @@ class RuleSet(models.Model):
|
|||||||
# Models which currently do not require permissions
|
# Models which currently do not require permissions
|
||||||
'common_attachment',
|
'common_attachment',
|
||||||
'common_customunit',
|
'common_customunit',
|
||||||
|
'common_dataoutput',
|
||||||
'common_inventreesetting',
|
'common_inventreesetting',
|
||||||
'common_inventreeusersetting',
|
'common_inventreeusersetting',
|
||||||
'common_notificationentry',
|
'common_notificationentry',
|
||||||
|
@ -1,19 +1,12 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { notifications, showNotification } from '@mantine/notifications';
|
import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react';
|
||||||
import {
|
|
||||||
IconCircleCheck,
|
|
||||||
IconPrinter,
|
|
||||||
IconReport,
|
|
||||||
IconTags
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { api } from '../../App';
|
import { api } from '../../App';
|
||||||
import { useApi } from '../../contexts/ApiContext';
|
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import type { ModelType } from '../../enums/ModelType';
|
import type { ModelType } from '../../enums/ModelType';
|
||||||
import { extractAvailableFields } from '../../functions/forms';
|
import { extractAvailableFields } from '../../functions/forms';
|
||||||
import { generateUrl } from '../../functions/urls';
|
import useDataOutput from '../../hooks/UseDataOutput';
|
||||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
import {
|
import {
|
||||||
@ -22,94 +15,6 @@ import {
|
|||||||
} from '../../states/SettingsState';
|
} from '../../states/SettingsState';
|
||||||
import type { ApiFormFieldSet } from '../forms/fields/ApiFormField';
|
import type { ApiFormFieldSet } from '../forms/fields/ApiFormField';
|
||||||
import { ActionDropdown } from '../items/ActionDropdown';
|
import { ActionDropdown } from '../items/ActionDropdown';
|
||||||
import { ProgressBar } from '../items/ProgressBar';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to track the progress of a printing operation
|
|
||||||
*/
|
|
||||||
function usePrintingProgress({
|
|
||||||
title,
|
|
||||||
outputId,
|
|
||||||
endpoint
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
outputId?: number;
|
|
||||||
endpoint: ApiEndpoints;
|
|
||||||
}) {
|
|
||||||
const api = useApi();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!!outputId) {
|
|
||||||
setLoading(true);
|
|
||||||
showNotification({
|
|
||||||
id: `printing-progress-${endpoint}-${outputId}`,
|
|
||||||
title: title,
|
|
||||||
loading: true,
|
|
||||||
autoClose: false,
|
|
||||||
withCloseButton: false,
|
|
||||||
message: <ProgressBar size='lg' value={0} progressLabel />
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [outputId, endpoint, title]);
|
|
||||||
|
|
||||||
const progress = useQuery({
|
|
||||||
enabled: !!outputId && loading,
|
|
||||||
refetchInterval: 750,
|
|
||||||
queryKey: ['printingProgress', endpoint, outputId],
|
|
||||||
queryFn: () =>
|
|
||||||
api
|
|
||||||
.get(apiUrl(endpoint, outputId))
|
|
||||||
.then((response) => {
|
|
||||||
const data = response?.data ?? {};
|
|
||||||
|
|
||||||
if (data.pk && data.pk == outputId) {
|
|
||||||
if (data.complete) {
|
|
||||||
setLoading(false);
|
|
||||||
notifications.hide(`printing-progress-${endpoint}-${outputId}`);
|
|
||||||
notifications.hide('print-success');
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
id: 'print-success',
|
|
||||||
title: t`Printing`,
|
|
||||||
message: t`Printing completed successfully`,
|
|
||||||
color: 'green',
|
|
||||||
icon: <IconCircleCheck />
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.output) {
|
|
||||||
const url = generateUrl(data.output);
|
|
||||||
window.open(url.toString(), '_blank');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
notifications.update({
|
|
||||||
id: `printing-progress-${endpoint}-${outputId}`,
|
|
||||||
autoClose: false,
|
|
||||||
withCloseButton: false,
|
|
||||||
message: (
|
|
||||||
<ProgressBar
|
|
||||||
size='lg'
|
|
||||||
value={data.progress}
|
|
||||||
maximum={data.items}
|
|
||||||
progressLabel
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
notifications.hide(`printing-progress-${endpoint}-${outputId}`);
|
|
||||||
setLoading(false);
|
|
||||||
return {};
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PrintingActions({
|
export function PrintingActions({
|
||||||
items,
|
items,
|
||||||
@ -142,16 +47,14 @@ export function PrintingActions({
|
|||||||
const [labelId, setLabelId] = useState<number | undefined>(undefined);
|
const [labelId, setLabelId] = useState<number | undefined>(undefined);
|
||||||
const [reportId, setReportId] = useState<number | undefined>(undefined);
|
const [reportId, setReportId] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
const labelProgress = usePrintingProgress({
|
const labelProgress = useDataOutput({
|
||||||
title: t`Printing Labels`,
|
title: t`Printing Labels`,
|
||||||
outputId: labelId,
|
id: labelId
|
||||||
endpoint: ApiEndpoints.label_output
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const reportProgress = usePrintingProgress({
|
const reportProgress = useDataOutput({
|
||||||
title: t`Printing Reports`,
|
title: t`Printing Reports`,
|
||||||
outputId: reportId,
|
id: reportId
|
||||||
endpoint: ApiEndpoints.report_output
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch available printing fields via OPTIONS request
|
// Fetch available printing fields via OPTIONS request
|
||||||
|
@ -21,12 +21,12 @@ import {
|
|||||||
IconCalendarMonth,
|
IconCalendarMonth,
|
||||||
IconChevronLeft,
|
IconChevronLeft,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
|
IconDownload,
|
||||||
IconFilter
|
IconFilter
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import type { CalendarState } from '../../hooks/UseCalendar';
|
import type { CalendarState } from '../../hooks/UseCalendar';
|
||||||
import { useLocalState } from '../../states/LocalState';
|
import { useLocalState } from '../../states/LocalState';
|
||||||
import { DownloadAction } from '../../tables/DownloadAction';
|
|
||||||
import type { TableFilter } from '../../tables/Filter';
|
import type { TableFilter } from '../../tables/Filter';
|
||||||
import { FilterSelectDrawer } from '../../tables/FilterSelectDrawer';
|
import { FilterSelectDrawer } from '../../tables/FilterSelectDrawer';
|
||||||
import { TableSearchInput } from '../../tables/Search';
|
import { TableSearchInput } from '../../tables/Search';
|
||||||
@ -35,7 +35,6 @@ import { ActionButton } from '../buttons/ActionButton';
|
|||||||
import { StylishText } from '../items/StylishText';
|
import { StylishText } from '../items/StylishText';
|
||||||
|
|
||||||
export interface InvenTreeCalendarProps extends CalendarOptions {
|
export interface InvenTreeCalendarProps extends CalendarOptions {
|
||||||
downloadData?: (fileFormat: string) => void;
|
|
||||||
enableDownload?: boolean;
|
enableDownload?: boolean;
|
||||||
enableFilters?: boolean;
|
enableFilters?: boolean;
|
||||||
enableSearch?: boolean;
|
enableSearch?: boolean;
|
||||||
@ -45,7 +44,6 @@ export interface InvenTreeCalendarProps extends CalendarOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Calendar({
|
export default function Calendar({
|
||||||
downloadData,
|
|
||||||
enableDownload,
|
enableDownload,
|
||||||
enableFilters = false,
|
enableFilters = false,
|
||||||
enableSearch,
|
enableSearch,
|
||||||
@ -88,6 +86,7 @@ export default function Calendar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{state.exportModal.modal}
|
||||||
{enableFilters && filters && (filters?.length ?? 0) > 0 && (
|
{enableFilters && filters && (filters?.length ?? 0) > 0 && (
|
||||||
<Boundary label={`InvenTreeCalendarFilterDrawer-${state.name}`}>
|
<Boundary label={`InvenTreeCalendarFilterDrawer-${state.name}`}>
|
||||||
<FilterSelectDrawer
|
<FilterSelectDrawer
|
||||||
@ -154,7 +153,7 @@ export default function Calendar({
|
|||||||
variant='transparent'
|
variant='transparent'
|
||||||
aria-label='calendar-select-filters'
|
aria-label='calendar-select-filters'
|
||||||
>
|
>
|
||||||
<Tooltip label={t`Calendar Filters`}>
|
<Tooltip label={t`Calendar Filters`} position='top-end'>
|
||||||
<IconFilter
|
<IconFilter
|
||||||
onClick={() => setFiltersVisible(!filtersVisible)}
|
onClick={() => setFiltersVisible(!filtersVisible)}
|
||||||
/>
|
/>
|
||||||
@ -163,10 +162,14 @@ export default function Calendar({
|
|||||||
</Indicator>
|
</Indicator>
|
||||||
)}
|
)}
|
||||||
{enableDownload && (
|
{enableDownload && (
|
||||||
<DownloadAction
|
<ActionIcon
|
||||||
key='download-action'
|
variant='transparent'
|
||||||
downloadCallback={downloadData}
|
aria-label='calendar-export-data'
|
||||||
/>
|
>
|
||||||
|
<Tooltip label={t`Download data`} position='top-end'>
|
||||||
|
<IconDownload onClick={state.exportModal.open} />
|
||||||
|
</Tooltip>
|
||||||
|
</ActionIcon>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user