2
0
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:
Oliver 2025-03-18 11:35:44 +11:00 committed by GitHub
parent 947a1bcc3a
commit 8d51aa1563
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
122 changed files with 2434 additions and 1504 deletions

View File

@ -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/).

View File

@ -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 %}

View File

@ -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.

View File

@ -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.

View File

@ -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 |

View 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") }}

View File

@ -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

View File

@ -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:]

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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]

View File

@ -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

View File

@ -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."""

View File

@ -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:

View File

@ -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',

View File

@ -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:

View File

@ -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,

View File

@ -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."""

View File

@ -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,

View File

@ -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(),

View File

@ -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."""

View File

@ -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 = [

View File

@ -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 = []

View File

@ -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:

View 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,
),
),
],
),
]

View File

@ -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)

View File

@ -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
)

View File

@ -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 = []

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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,

View 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

View 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)

View 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'),
)

View 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)

View File

@ -1,4 +1,4 @@
"""Generic event enumerations for InevnTree.""" """Generic event enumerations for InvenTree."""
import enum import enum

View File

@ -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

View File

@ -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)

View File

@ -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."""

View File

@ -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,

View File

@ -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,

View File

@ -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."""

View File

@ -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)
"""

View File

@ -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)

View File

@ -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',

View File

@ -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."""

View File

@ -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',
] ]

View File

@ -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:

View File

@ -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.

View File

@ -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

View File

@ -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."""

View File

@ -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__)

View File

@ -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):

View File

@ -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):

View File

@ -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(

View File

@ -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.

View 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)

View File

@ -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):

View File

@ -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.

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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."""

View File

@ -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)

View File

@ -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')

View File

@ -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(

View File

@ -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.

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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):

View 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

View File

@ -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

View File

@ -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.

View File

@ -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',

View File

@ -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>',

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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/',

View File

@ -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)

View File

@ -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",
),
]

View File

@ -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.

View File

@ -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."""

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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.

View File

@ -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):

View File

@ -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',

View File

@ -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

View File

@ -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