2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-27 19:16: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
This documentation is for API version: `171`
This documentation is for API version: `315`
!!! 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/).

View File

@ -119,7 +119,7 @@ Select a part in the list and click on "Add Substitute" button to confirm.
### 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" %}
{% include 'img.html' %}
{% 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 |
| [BarcodeMixin](./plugins/barcode.md) | Support custom barcode actions |
| [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 |
| [LabelPrintingMixin](./plugins/label.md) | Custom label printing support |
| [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
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

View File

@ -102,7 +102,7 @@ def check_link(url) -> bool:
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."""
# Check if we are in ReadTheDocs
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
"""
if branch == None:
branch = get_build_enviroment()
branch = get_build_environment()
if dirname.startswith('/'):
dirname = dirname[1:]
@ -173,7 +173,7 @@ def define_env(env):
- FileNotFoundError: If the file does not exist, or the generated URL is invalid
"""
if branch == None:
branch = get_build_enviroment()
branch = get_build_environment()
if filename.startswith('/'):
filename = filename[1:]

View File

@ -130,8 +130,6 @@ nav:
- Allocating Stock: build/allocate.md
- Example Build Order: build/example.md
- Bill of Materials: build/bom.md
- Importing BOM Data: build/bom_import.md
- Exporting BOM Data: build/bom_export.md
- Orders:
- Companies: order/company.md
- Purchase Orders: order/purchase_order.md
@ -208,6 +206,7 @@ nav:
- App Mixin: extend/plugins/app.md
- Barcode Mixin: extend/plugins/barcode.md
- Currency Mixin: extend/plugins/currency.md
- Data Export Mixin: extend/plugins/export.md
- Event Mixin: extend/plugins/event.md
- Icon Pack Mixin: extend/plugins/icon.md
- Label Printing Mixin: extend/plugins/label.md

View File

@ -1,13 +1,18 @@
"""InvenTree API version information."""
# 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."""
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
- Adds the option for superusers to list all user tokens
- Make list endpoints sortable, filterable and searchable

View File

@ -88,6 +88,8 @@ class InvenTreeConfig(AppConfig):
'InvenTree.tasks.delete_expired_sessions',
'stock.tasks.delete_old_stock_items',
'label.tasks.cleanup_old_label_outputs',
'report.tasks.cleanup_old_report_outputs',
'data_exporter.tasks.cleanup_old_export_outputs',
]
try:

View File

@ -22,7 +22,7 @@ class InvenTreeExchange(SimpleExchangeBackend):
def get_rates(self, **kwargs) -> dict:
"""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())
symbols = kwargs.get('symbols', currency_codes())
@ -34,7 +34,9 @@ class InvenTreeExchange(SimpleExchangeBackend):
if not 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:
plugin = plugins[0]

View File

@ -384,9 +384,14 @@ def WrapWithQuotes(text, quote='"'):
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 ['csv', 'xlsx', 'tsv', 'json']
return [opt[0] for opt in GetExportOptions()]
def DownloadFile(
@ -437,14 +442,14 @@ def increment_serial_number(serial, part=None):
incremented value, or None if incrementing could not be performed.
"""
from InvenTree.exceptions import log_error
from plugin.registry import registry
from plugin import PluginMixinEnum, registry
# Ensure we start with a string value
if serial is not None:
serial = str(serial).strip()
# 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:
if not hasattr(plugin, 'increment_serial_number'):
continue

View File

@ -5,6 +5,8 @@ from django.core.exceptions import FieldDoesNotExist
from rest_framework import generics, mixins, status
from rest_framework.response import Response
import data_exporter.mixins
import importer.mixins
from InvenTree.fields import InvenTreeNotesField
from InvenTree.helpers import (
clean_markdown,
@ -197,3 +199,10 @@ class RetrieveDestroyAPI(generics.RetrieveDestroyAPIView):
class UpdateAPI(CleanMixin, generics.UpdateAPIView):
"""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):
"""Throw this model against the plugin validation interface."""
from plugin.registry import registry
from plugin import PluginMixinEnum, registry
deltas = self.get_field_deltas()
for plugin in registry.with_mixin('validation'):
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
if plugin.validate_model_instance(self, deltas=deltas) is True:
return
@ -130,9 +130,9 @@ class PluginValidationMixin(DiffMixin):
Note: Each plugin may raise a ValidationError to prevent deletion.
"""
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:
plugin.validate_model_deletion(self)
except ValidationError as e:

View File

@ -250,14 +250,15 @@ INSTALLED_APPS = [
# InvenTree apps
'build.apps.BuildConfig',
'common.apps.CommonConfig',
'company.apps.CompanyConfig',
'plugin.apps.PluginAppConfig', # Plugin app runs before all apps that depend on the isPluginRegistryLoaded function
'company.apps.CompanyConfig',
'order.apps.OrderConfig',
'part.apps.PartConfig',
'report.apps.ReportConfig',
'stock.apps.StockConfig',
'users.apps.UsersConfig',
'machine.apps.MachineConfig',
'data_exporter.apps.DataExporterConfig',
'importer.apps.ImporterConfig',
'web',
'generic',

View File

@ -280,14 +280,15 @@ class ScheduledTask:
interval: str
minutes: Optional[int] = None
MINUTES = 'I'
HOURLY = 'H'
DAILY = 'D'
WEEKLY = 'W'
MONTHLY = 'M'
QUARTERLY = 'Q'
YEARLY = 'Y'
TYPE = [MINUTES, HOURLY, DAILY, WEEKLY, MONTHLY, QUARTERLY, YEARLY]
MINUTES: str = 'I'
HOURLY: str = 'H'
DAILY: str = 'D'
WEEKLY: str = 'W'
MONTHLY: str = 'M'
QUARTERLY: str = 'Q'
YEARLY: str = 'Y'
TYPE: tuple[str] = (MINUTES, HOURLY, DAILY, WEEKLY, MONTHLY, QUARTERLY, YEARLY)
class TaskRegister:

View File

@ -486,7 +486,13 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
)
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."""
response = self.client.get(url, data=data, format='json')
@ -502,9 +508,11 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
# Extract filename
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:
self.assertRegex(fn, expected_fn)
@ -524,6 +532,72 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
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(
self,
file_object,

View File

@ -16,8 +16,8 @@ import common.models
import part.models as part_models
from build.models import Build, BuildItem, BuildLine
from build.status_codes import BuildStatus, BuildStatusGroups
from data_exporter.mixins import DataExportViewMixin
from generic.states.api import StatusView
from importer.mixins import DataExportViewMixin
from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS, InvenTreeDateFilter
from InvenTree.helpers import isNull, str2bool
@ -388,7 +388,7 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
kwargs['part_detail'] = part_detail
kwargs['create'] = True
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
class BuildDetail(BuildMixin, RetrieveUpdateDestroyAPI):
@ -529,7 +529,7 @@ class BuildLineEndpoint:
except AttributeError:
pass
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
def get_source_build(self) -> Build:
"""Return the source Build object for the BuildLine queryset.
@ -838,7 +838,7 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
except AttributeError:
pass
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
def get_queryset(self):
"""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.settings import get_global_setting
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from importer.mixins import DataImportExportSerializerMixin
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema
from InvenTree.serializers import (
InvenTreeDecimalField,

View File

@ -449,7 +449,6 @@ class BuildTest(BuildAPITest):
'Build Status',
'Completed items',
'Batch Code',
'Notes',
'Description',
'Part',
'Part Name',
@ -459,9 +458,9 @@ class BuildTest(BuildAPITest):
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(
file,
data_file,
required_cols=required_cols,
excluded_cols=excluded_cols,
required_rows=Build.objects.count(),

View File

@ -33,6 +33,15 @@ class AttachmentAdmin(admin.ModelAdmin):
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)
class BarcodeScanResultAdmin(admin.ModelAdmin):
"""Admin interface for BarcodeScanResult objects."""

View File

@ -31,8 +31,8 @@ import common.serializers
import InvenTree.conversion
from common.icons import get_icon_packs
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 importer.mixins import DataExportViewMixin
from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.config import CONFIG_LOOKUPS
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
@ -854,6 +854,25 @@ class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI):
"""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 = [
path(
'<int:pk>/',
@ -1096,6 +1115,16 @@ common_api_urls = [
path('icons/', IconList.as_view(), name='api-icon-list'),
# Selection lists
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 = [

View File

@ -132,9 +132,9 @@ def validate_currency_codes(value):
def currency_exchange_plugins() -> Optional[list]:
"""Return a list of plugin choices which can be used for currency exchange."""
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:
plugs = []

View File

@ -73,9 +73,9 @@ def get_icon_packs():
]
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:
icon_packs.extend(plugin.icon_packs())
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 enum
import hashlib
import hmac
import json
@ -2312,3 +2313,56 @@ class BarcodeScanResult(InvenTree.models.InvenTreeModel):
help_text=_('Was the barcode scan successful?'),
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.validators
import generic.states.custom
from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer
from InvenTree.helpers import get_objectreference
from InvenTree.helpers_model import construct_absolute_url
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
@ -757,7 +757,7 @@ class SelectionListSerializer(InvenTreeModelSerializer):
def update(self, instance, validated_data):
"""Update an existing selection list. Save the choices separately."""
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.
ret = []
@ -772,7 +772,7 @@ class SelectionListSerializer(InvenTreeModelSerializer):
ret.append(SelectionEntrySerializer().update(inst, data))
# 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()
return super().update(instance, validated_data)
@ -783,3 +783,32 @@ class SelectionListSerializer(InvenTreeModelSerializer):
if self.instance and self.instance.locked:
raise serializers.ValidationError({'locked': _('Selection list is locked')})
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:
"""Return a list of plugin choices which can be used for barcode generation."""
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
plugins = []

View File

@ -4,13 +4,13 @@ from django.core.validators import MinValueValidator
from django.utils.translation import gettext_lazy as _
from common.setting.type import InvenTreeSettingsKeyType
from plugin import registry
from plugin import PluginMixinEnum, registry
def label_printer_options():
"""Build a list of available label printer options."""
printers = []
label_printer_plugins = registry.with_mixin('labels')
label_printer_plugins = registry.with_mixin(PluginMixinEnum.LABELS)
if label_printer_plugins:
printers.extend([
(p.slug, p.name + ' - ' + p.human_name) for p in label_printer_plugins

View File

@ -12,6 +12,7 @@ import feedparser
import requests
import structlog
import common.models
import InvenTree.helpers
from InvenTree.helpers_model import getModelsWithMixin
from InvenTree.models import InvenTreeNotesMixin
@ -20,6 +21,17 @@ from InvenTree.tasks import ScheduledTask, scheduled_task
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)
def delete_old_notifications():
"""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
import part.models
from importer.mixins import DataExportViewMixin
from data_exporter.mixins import DataExportViewMixin
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
from InvenTree.helpers import str2bool
@ -176,7 +176,7 @@ class ManufacturerPartList(DataExportViewMixin, ListCreateDestroyAPIView):
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
filter_backends = SEARCH_ORDER_FILTER
@ -245,7 +245,7 @@ class ManufacturerPartParameterList(ListCreateDestroyAPIView):
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
filter_backends = SEARCH_ORDER_FILTER
@ -355,7 +355,7 @@ class SupplierPartMixin:
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
class SupplierPartList(
@ -467,7 +467,7 @@ class SupplierPriceBreakList(ListCreateAPI):
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
filter_backends = SEARCH_ORDER_FILTER_ALIAS

View File

@ -12,8 +12,8 @@ from taggit.serializers import TagListSerializerField
import company.filters
import part.filters
import part.serializers as part_serializers
from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema
from InvenTree.serializers import (
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

View File

@ -11,7 +11,7 @@ from rest_framework.response import Response
import common.models
import common.serializers
from importer.mixins import DataExportViewMixin
from data_exporter.mixins import DataExportViewMixin
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from InvenTree.permissions import IsStaffOrReadOnly

View File

@ -1,14 +1,8 @@
"""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 taggit.serializers import TagListSerializerField
from InvenTree.helpers import DownloadFile, GetExportFormats, current_date
class DataImportSerializerMixin:
"""Mixin class for adding data import functionality to a DRF serializer."""
@ -89,242 +83,3 @@ class DataImportSerializerMixin:
importable_fields[name] = field
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.settings
import company.models
from data_exporter.mixins import DataExportViewMixin
from generic.states.api import StatusView
from importer.mixins import DataExportViewMixin
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import (
SEARCH_ORDER_FILTER,
@ -57,7 +57,7 @@ class GeneralExtraLineList(DataExportViewMixin):
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return the annotated queryset for this endpoint."""
@ -336,7 +336,7 @@ class PurchaseOrderMixin:
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return the annotated queryset for this endpoint."""
@ -594,7 +594,7 @@ class PurchaseOrderLineItemMixin:
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
def perform_update(self, serializer):
"""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
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for this endpoint."""
@ -942,7 +942,7 @@ class SalesOrderLineItemMixin:
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for this endpoint."""
@ -1221,7 +1221,7 @@ class SalesOrderAllocationList(SalesOrderAllocationMixin, BulkUpdateMixin, ListA
except AttributeError:
pass
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
class SalesOrderAllocationDetail(SalesOrderAllocationMixin, RetrieveUpdateDestroyAPI):
@ -1385,7 +1385,7 @@ class ReturnOrderMixin:
# Ensure the context is passed through to the serializer
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for this endpoint."""
@ -1536,7 +1536,7 @@ class ReturnOrderLineItemMixin:
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for this endpoint."""

View File

@ -35,7 +35,6 @@ from company.serializers import (
SupplierPartSerializer,
)
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer
from InvenTree.helpers import (
current_date,
@ -44,6 +43,7 @@ from InvenTree.helpers import (
normalize,
str2bool,
)
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema
from InvenTree.serializers import (
InvenTreeCurrencySerializer,

View File

@ -870,14 +870,13 @@ class PurchaseOrderDownloadTest(OrderTest):
"""Incorrect format should default raise an error."""
url = reverse('api-po-list')
with self.assertRaises(ValueError):
self.download_file(url, {'export': 'xyz'})
response = self.export_data(url, export_format='xyz', expected_code=400)
self.assertIn('is not a valid choice', str(response['export_format']))
def test_download_csv(self):
"""Download PurchaseOrder data as .csv."""
with self.download_file(
with self.export_data(
reverse('api-po-list'),
{'export': 'csv'},
expected_code=200,
expected_fn=r'InvenTree_PurchaseOrder_.+\.csv',
) as file:
@ -896,12 +895,12 @@ class PurchaseOrderDownloadTest(OrderTest):
def test_download_line_items(self):
"""Test that the PurchaseOrderLineItems can be downloaded to a file."""
with self.download_file(
with self.export_data(
reverse('api-po-line-list'),
{'export': 'xlsx'},
decode=False,
export_format='xlsx',
expected_code=200,
expected_fn=r'InvenTree_PurchaseOrderLineItem.+\.xlsx',
decode=False,
) as file:
self.assertIsInstance(file, io.BytesIO)
@ -1602,9 +1601,9 @@ class SalesOrderTest(OrderTest):
# Download file, check we get a 200 response
for fmt in ['csv', 'xlsx', 'tsv']:
self.download_file(
self.export_data(
reverse('api-so-list'),
{'export': fmt},
export_format=fmt,
decode=fmt == 'csv',
expected_code=200,
expected_fn=r'InvenTree_SalesOrder_.+',
@ -1856,16 +1855,16 @@ class SalesOrderDownloadTest(OrderTest):
"""Test that downloading without the 'export' option fails."""
url = reverse('api-so-list')
with self.assertRaises(ValueError):
self.download_file(url, {}, expected_code=200)
response = self.export_data(url, export_plugin='no-plugin', expected_code=400)
self.assertIn('is not a valid choice', str(response['export_plugin']))
def test_download_xlsx(self):
"""Test xlsx file download."""
url = reverse('api-so-list')
# Download .xls file
with self.download_file(
url, {'export': 'xlsx'}, expected_code=200, decode=False
with self.export_data(
url, export_format='xlsx', expected_code=200, decode=False
) as file:
self.assertIsInstance(file, io.BytesIO)
@ -1888,9 +1887,7 @@ class SalesOrderDownloadTest(OrderTest):
excluded_cols = ['metadata']
# Download .xls file
with self.download_file(
url, {'export': 'csv'}, expected_code=200, decode=True
) as file:
with self.export_data(url, export_format='csv') as file:
data = self.process_csv(
file,
required_cols=required_cols,
@ -1905,9 +1902,7 @@ class SalesOrderDownloadTest(OrderTest):
self.assertEqual(line['Order Status'], str(order.status))
# Download only outstanding sales orders
with self.download_file(
url, {'export': 'tsv', 'outstanding': True}, expected_code=200, decode=True
) as file:
with self.export_data(url, {'outstanding': True}, export_format='tsv') as file:
self.process_csv(
file,
required_cols=required_cols,

View File

@ -19,7 +19,7 @@ import order.models
import part.filters
from build.models import Build, BuildItem
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.filters import (
ORDER_FILTER,
@ -69,6 +69,17 @@ class CategoryMixin:
serializer_class = part_serializers.CategorySerializer
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):
"""Return an annotated queryset for the CategoryDetail endpoint."""
queryset = super().get_queryset(*args, **kwargs)
@ -248,19 +259,6 @@ class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCrea
class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI):
"""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):
"""Perform 'update' function and mark this part as 'starred' (or not)."""
# Clean up input data
@ -1229,7 +1227,7 @@ class PartMixin:
except AttributeError:
pass
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
def get_serializer_context(self):
"""Extend serializer context data."""
@ -1600,7 +1598,7 @@ class PartParameterAPIMixin:
except AttributeError:
pass
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
class PartParameterFilter(rest_filters.FilterSet):
@ -1861,7 +1859,7 @@ class BomMixin:
# Ensure the request context is passed through!
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""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.
"""
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
# If the plugin returns 'True' we will skip any subsequent validation
@ -625,9 +625,9 @@ class Part(
- Validation is handled by custom plugins
- 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:
result = plugin.validate_part_ipn(self.IPN, self)
@ -724,10 +724,10 @@ class Part(
serial = str(serial).strip()
# First, throw the serial number against each of the loaded validation plugins
from plugin.registry import registry
from plugin import PluginMixinEnum, registry
try:
for plugin in registry.with_mixin('validation'):
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
# Run the serial number through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
@ -844,12 +844,12 @@ class Part(
Returns:
The latest serial number specified for this part, or None
"""
from plugin.registry import registry
from plugin import PluginMixinEnum, registry
if allow_plugins:
# Check with plugin system
# 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:
result = plugin.get_latest_serial_number(self)
if result is not None:
@ -3922,9 +3922,9 @@ class PartParameter(InvenTree.models.InvenTreeMetadataModel):
self.calculate_numeric_value()
# 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
try:
result = plugin.validate_part_parameter(self, self.data)

View File

@ -33,8 +33,8 @@ import part.tasks
import stock.models
import users.models
from build.status_codes import BuildStatusGroups
from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema
from InvenTree.tasks import offload_task
from users.serializers import UserSerializer
@ -1526,6 +1526,8 @@ class BomItemSerializer(
import_exclude_fields = ['validated', 'substitutes']
export_exclude_fields = ['substitutes']
export_child_fields = [
'sub_part_detail.name',
'sub_part_detail.IPN',

View File

@ -1180,7 +1180,6 @@ class PartAPITest(PartAPITestBase):
'Virtual',
'Trackable',
'Active',
'Notes',
'Creation Date',
'On Order',
'In Stock',
@ -1189,9 +1188,9 @@ class PartAPITest(PartAPITestBase):
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(
file,
data_file,
excluded_cols=excluded_cols,
required_cols=required_cols,
required_rows=Part.objects.count(),
@ -2628,6 +2627,46 @@ class BomItemTest(InvenTreeAPITestCase):
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):
"""Unit tests for the PartAttachment API endpoint."""

View File

@ -1,12 +1,13 @@
"""Utility file to enable simper imports."""
from .helpers import MixinImplementationError, MixinNotImplementedError
from .plugin import InvenTreePlugin
from .plugin import InvenTreePlugin, PluginMixinEnum
from .registry import registry
__all__ = [
'InvenTreePlugin',
'MixinImplementationError',
'MixinNotImplementedError',
'PluginMixinEnum',
'registry',
]

View File

@ -7,7 +7,7 @@ from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from InvenTree.exceptions import log_error
from plugin import registry
from plugin import PluginMixinEnum, registry
class ActionPluginSerializer(serializers.Serializer):
@ -32,7 +32,7 @@ class ActionPluginView(GenericAPIView):
if action is None:
return Response({'error': _('No action specified')})
action_plugins = registry.with_mixin('action')
action_plugins = registry.with_mixin(PluginMixinEnum.ACTION)
for plugin in action_plugins:
try:
if plugin.action_name() == action:

View File

@ -1,5 +1,7 @@
"""Plugin mixin classes for action plugin."""
from plugin import PluginMixinEnum
class ActionMixin:
"""Mixin that enables custom actions."""
@ -14,7 +16,7 @@ class ActionMixin:
def __init__(self):
"""Register mixin."""
super().__init__()
self.add_mixin('action', True, __class__)
self.add_mixin(PluginMixinEnum.ACTION, True, __class__)
def action_name(self):
"""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.mixins import ListAPI, RetrieveDestroyAPI
from InvenTree.permissions import IsStaffOrReadOnly
from plugin import registry
from plugin import PluginMixinEnum, registry
from users.models import RuleSet
from . import serializers as barcode_serializers
@ -143,7 +143,7 @@ class BarcodeView(CreateAPIView):
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
plugin = None
@ -521,7 +521,7 @@ class BarcodePOReceive(BarcodeView):
except Exception:
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
plugin = None
@ -539,7 +539,7 @@ class BarcodePOReceive(BarcodeView):
raise ValidationError(response)
# Now, look just for "supplier-barcode" plugins
plugins = registry.with_mixin('supplier-barcode')
plugins = registry.with_mixin(PluginMixinEnum.SUPPLIER_BARCODE)
plugin_response = None

View File

@ -13,6 +13,7 @@ from InvenTree.exceptions import log_error
from InvenTree.models import InvenTreeBarcodeMixin
from order.models import PurchaseOrder
from part.models import Part
from plugin import PluginMixinEnum
from plugin.base.integration.SettingsMixin import SettingsMixin
logger = structlog.get_logger('inventree')
@ -34,7 +35,7 @@ class BarcodeMixin:
def __init__(self):
"""Register mixin."""
super().__init__()
self.add_mixin('barcode', 'has_barcode', __class__)
self.add_mixin(PluginMixinEnum.BARCODE, 'has_barcode', __class__)
@property
def has_barcode(self):
@ -104,7 +105,7 @@ class SupplierBarcodeMixin(BarcodeMixin):
def __init__(self):
"""Register mixin."""
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):
"""Return the value of a barcode field."""

View File

@ -1,5 +1,6 @@
"""Plugin mixin class for events."""
from plugin import PluginMixinEnum
from plugin.helpers import MixinNotImplementedError
@ -33,4 +34,4 @@ class EventMixin:
def __init__(self):
"""Register the mixin."""
super().__init__()
self.add_mixin('events', True, __class__)
self.add_mixin(PluginMixinEnum.EVENTS, True, __class__)

View File

@ -3,6 +3,7 @@
import structlog
from common.icons import IconPack, reload_icon_packs
from plugin import PluginMixinEnum
from plugin.helpers import MixinNotImplementedError
logger = structlog.get_logger('inventree')
@ -19,7 +20,7 @@ class IconPackMixin:
def __init__(self):
"""Register mixin."""
super().__init__()
self.add_mixin('icon_pack', True, __class__)
self.add_mixin(PluginMixinEnum.ICON_PACK, True, __class__)
@classmethod
def _activate_mixin(cls, registry, plugins, *args, **kwargs):

View File

@ -7,6 +7,7 @@ from typing import Optional
import requests
import structlog
from plugin import PluginMixinEnum
from plugin.helpers import MixinNotImplementedError
logger = structlog.get_logger('inventree')
@ -72,7 +73,7 @@ class APICallMixin:
def __init__(self):
"""Register mixin."""
super().__init__()
self.add_mixin('api_call', 'has_api_call', __class__)
self.add_mixin(PluginMixinEnum.API_CALL, 'has_api_call', __class__)
@property
def has_api_call(self):

View File

@ -11,6 +11,7 @@ from django.contrib import admin
import structlog
from InvenTree.config import get_plugin_dir
from plugin import PluginMixinEnum
logger = structlog.get_logger('inventree')
@ -26,7 +27,7 @@ class AppMixin:
def __init__(self):
"""Register mixin."""
super().__init__()
self.add_mixin('app', 'has_app', __class__)
self.add_mixin(PluginMixinEnum.APP, 'has_app', __class__)
@classmethod
def _activate_mixin(

View File

@ -1,5 +1,6 @@
"""Plugin mixin class for supporting currency exchange data."""
from plugin import PluginMixinEnum
from plugin.helpers import MixinNotImplementedError
@ -21,7 +22,7 @@ class CurrencyExchangeMixin:
def __init__(self):
"""Register the mixin."""
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:
"""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
from plugin import PluginMixinEnum
from plugin.helpers import MixinNotImplementedError
logger = structlog.get_logger('inventree')
@ -21,7 +22,7 @@ class NavigationMixin:
def __init__(self):
"""Register mixin."""
super().__init__()
self.add_mixin('navigation', 'has_navigation', __class__)
self.add_mixin(PluginMixinEnum.NAVIGATION, 'has_navigation', __class__)
self.navigation = self.setup_navigation()
def setup_navigation(self):

View File

@ -1,5 +1,7 @@
"""Plugin mixin class for ReportContextMixin."""
from plugin import PluginMixinEnum
class ReportMixin:
"""Mixin which provides additional context to generated reports.
@ -20,7 +22,7 @@ class ReportMixin:
def __init__(self):
"""Register mixin."""
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):
"""Add extra context to the provided report instance.

View File

@ -6,6 +6,7 @@ from django.db.utils import OperationalError, ProgrammingError
import structlog
from common.settings import get_global_setting
from plugin import PluginMixinEnum
from plugin.helpers import MixinImplementationError
logger = structlog.get_logger('inventree')
@ -54,7 +55,7 @@ class ScheduleMixin:
self.scheduled_tasks = []
self.add_mixin('schedule', 'has_scheduled_tasks', __class__)
self.add_mixin(PluginMixinEnum.SCHEDULE, 'has_scheduled_tasks', __class__)
@classmethod
def _activate_mixin(cls, registry, plugins, *args, **kwargs):

View File

@ -6,6 +6,8 @@ from django.db.utils import OperationalError, ProgrammingError
import structlog
from plugin import PluginMixinEnum
logger = structlog.get_logger('inventree')
# import only for typechecking, otherwise this throws a model is unready error
@ -30,7 +32,7 @@ class SettingsMixin:
def __init__(self):
"""Register mixin."""
super().__init__()
self.add_mixin('settings', 'has_settings', __class__)
self.add_mixin(PluginMixinEnum.SETTINGS, 'has_settings', __class__)
self.settings = getattr(self, 'SETTINGS', {})
@classmethod

View File

@ -6,6 +6,7 @@ from django.urls import include, re_path
import structlog
from common.settings import get_global_setting
from plugin import PluginMixinEnum
from plugin.urls import PLUGIN_BASE
logger = structlog.get_logger('inventree')
@ -22,7 +23,7 @@ class UrlsMixin:
def __init__(self):
"""Register mixin."""
super().__init__()
self.add_mixin('urls', 'has_urls', __class__)
self.add_mixin(PluginMixinEnum.URLS, 'has_urls', __class__)
self.urls = self.setup_urls()
@classmethod

View File

@ -7,6 +7,7 @@ from django.db.models import Model
import part.models
import stock.models
from plugin import PluginMixinEnum
class ValidationMixin:
@ -46,7 +47,7 @@ class ValidationMixin:
def __init__(self):
"""Register the mixin."""
super().__init__()
self.add_mixin('validation', True, __class__)
self.add_mixin(PluginMixinEnum.VALIDATION, True, __class__)
def raise_error(self, message):
"""Raise a ValidationError with the given message."""

View File

@ -9,12 +9,13 @@ import pdf2image
from rest_framework import serializers
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.tasks import offload_task
from plugin import PluginMixinEnum
from plugin.base.label import label as plugin_label
from plugin.helpers import MixinNotImplementedError
from report.models import LabelTemplate, TemplateOutput
from report.models import LabelTemplate
class LabelPrintingMixin:
@ -35,7 +36,7 @@ class LabelPrintingMixin:
def __init__(self): # pragma: no cover
"""Register mixin."""
super().__init__()
self.add_mixin('labels', True, __class__)
self.add_mixin(PluginMixinEnum.LABELS, True, __class__)
BLOCKING_PRINT = True
@ -104,7 +105,7 @@ class LabelPrintingMixin:
def print_labels(
self,
label: LabelTemplate,
output: TemplateOutput,
output: DataOutput,
items: list,
request: Request,
**kwargs,
@ -113,7 +114,7 @@ class LabelPrintingMixin:
Arguments:
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)
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
Returns:
None. Output data should be stored in the provided TemplateOutput object
None. Output data should be stored in the provided DataOutput object
Raises:
ValidationError if there is an error during the print process
@ -211,7 +212,7 @@ class LabelPrintingMixin:
filename: The filename of this PDF label
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
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
width: The expected width 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.unit_test import InvenTreeAPITestCase
from part.models import Part
from plugin import InvenTreePlugin, PluginMixinEnum, registry
from plugin.base.label.mixins import LabelPrintingMixin
from plugin.helpers import MixinNotImplementedError
from plugin.plugin import InvenTreePlugin
from plugin.registry import registry
from report.models import LabelTemplate
from report.tests import PrintTestMixins
from stock.models import StockItem, StockLocation
@ -48,11 +47,11 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
def test_installed(self):
"""Test that the sample printing plugin is installed."""
# Get all label plugins
plugins = registry.with_mixin('labels', active=None)
plugins = registry.with_mixin(PluginMixinEnum.LABELS, active=None)
self.assertEqual(len(plugins), 4)
# 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)
def test_api(self):
@ -121,7 +120,7 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
self.assertIn('Plugin does not support label printing', str(response.data))
# Find available plugins
plugins = registry.with_mixin('labels')
plugins = registry.with_mixin(PluginMixinEnum.LABELS)
self.assertGreater(len(plugins), 0)
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.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
@ -39,7 +40,7 @@ class LocatePluginView(GenericAPIView):
raise ParseError("'plugin' field must be supplied")
# 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]:
raise ParseError(

View File

@ -2,6 +2,7 @@
import structlog
from plugin import PluginMixinEnum
from plugin.helpers import MixinNotImplementedError
logger = structlog.get_logger('inventree')
@ -31,7 +32,7 @@ class LocateMixin:
def __init__(self):
"""Register the mixin."""
super().__init__()
self.add_mixin('locate', True, __class__)
self.add_mixin(PluginMixinEnum.LOCATE, True, __class__)
def locate_stock_item(self, item_pk):
"""Attempt to locate a particular StockItem.

View File

@ -3,7 +3,7 @@
from django.urls import reverse
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 stock.models import StockItem, StockLocation
@ -24,7 +24,7 @@ class LocatePluginTests(InvenTreeAPITestCase):
def test_installed(self):
"""Test that a locate plugin is actually installed."""
plugins = registry.with_mixin('locate')
plugins = registry.with_mixin(PluginMixinEnum.LOCATE)
self.assertGreater(len(plugins), 0)

View File

@ -10,7 +10,7 @@ from rest_framework.views import APIView
import plugin.base.ui.serializers as UIPluginSerializers
from common.settings import get_global_setting
from InvenTree.exceptions import log_error
from plugin import registry
from plugin import PluginMixinEnum, registry
class PluginUIFeatureList(APIView):
@ -28,7 +28,9 @@ class PluginUIFeatureList(APIView):
if get_global_setting('ENABLE_PLUGINS_INTERFACE'):
# 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
try:

View File

@ -8,6 +8,8 @@ from typing import Literal, TypedDict
import structlog
from rest_framework.request import Request
from plugin import PluginMixinEnum
logger = structlog.get_logger('inventree')
@ -79,7 +81,7 @@ class UserInterfaceMixin:
def __init__(self):
"""Register mixin."""
super().__init__()
self.add_mixin('ui', True, __class__) # type: ignore
self.add_mixin(PluginMixinEnum.USER_INTERFACE, True, __class__) # type: ignore
def get_ui_features(
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 InvenTree.unit_test import InvenTreeAPITestCase
from plugin.registry import registry
from plugin import PluginMixinEnum, registry
class UserInterfaceMixinTests(InvenTreeAPITestCase):
@ -30,7 +30,7 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
plugin = registry.get_plugin('sampleui')
self.assertTrue(plugin.is_active())
plugins = registry.with_mixin('ui')
plugins = registry.with_mixin(PluginMixinEnum.USER_INTERFACE)
self.assertGreater(len(plugins), 0)
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
import report.helpers
from common.models import DataOutput
from InvenTree.helpers import str2bool
from plugin import InvenTreePlugin
from plugin.mixins import LabelPrintingMixin, SettingsMixin
from report.models import LabelOutput, LabelTemplate
from report.models import LabelTemplate
logger = structlog.get_logger('inventree')
@ -76,7 +77,7 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
PrintingOptionsSerializer = LabelPrintingOptionsSerializer
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.

View File

@ -8,6 +8,7 @@ from plugin.base.icons.mixins import IconPackMixin
from plugin.base.integration.APICallMixin import APICallMixin
from plugin.base.integration.AppMixin import AppMixin
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.ReportMixin import ReportMixin
from plugin.base.integration.ScheduleMixin import ScheduleMixin
@ -25,6 +26,7 @@ __all__ = [
'BarcodeMixin',
'BulkNotificationMethod',
'CurrencyExchangeMixin',
'DataExportMixin',
'EventMixin',
'IconPackMixin',
'LabelPrintingMixin',

View File

@ -1,12 +1,13 @@
"""Base Class for InvenTree plugins."""
import enum
import inspect
import warnings
from datetime import datetime
from distutils.sysconfig import get_python_lib
from importlib.metadata import PackageNotFoundError, metadata
from pathlib import Path
from typing import Optional
from typing import Optional, Union
from django.conf import settings
from django.utils.text import slugify
@ -20,6 +21,36 @@ from plugin.helpers import get_git_log
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:
"""Base class for a plugins metadata."""
@ -126,12 +157,15 @@ class MixinBase:
self._mixins = {}
super().__init__(*args, **kwargs)
def mixin(self, key):
def mixin(self, key: str) -> bool:
"""Check if mixin is registered."""
key = str(key).lower()
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."""
key = str(key).lower()
if self.mixin(key):
fnc_name = self._mixins.get(key)
@ -150,6 +184,8 @@ class MixinBase:
def add_mixin(self, key: str, fnc_enabled=True, cls=None):
"""Add a mixin to the plugins registry."""
key = str(key).lower()
self._mixins[key] = fnc_enabled
self.setup_mixin(key, cls=cls)
@ -230,7 +266,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
Set paths and load metadata.
"""
super().__init__()
self.add_mixin('base')
self.add_mixin(PluginMixinEnum.BASE)
self.define_package()
@ -351,7 +387,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
return self.check_package_path()
@classmethod
def check_package_install_name(cls) -> [str, None]:
def check_package_install_name(cls) -> Union[str, None]:
"""Installable package name of the plugin.
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)
@property
def package_install_name(self) -> [str, None]:
def package_install_name(self) -> Union[str, None]:
"""Installable package name of the plugin.
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 self.loading_lock.locked()
def get_plugin(self, slug, active=None):
"""Lookup plugin by slug (unique key)."""
def get_plugin(self, slug, active=None, with_mixin=None):
"""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
self.check_reload()
@ -104,6 +110,9 @@ class PluginsRegistry:
if active is not None and active != plg.is_active():
return None
if with_mixin is not None and not plg.mixin_enabled(with_mixin):
return None
return plg
def get_plugin_config(self, slug: str, name: Union[str, None] = None):
@ -191,7 +200,9 @@ class PluginsRegistry:
return plugin_func(*args, **kwargs)
# 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.
Args:
@ -202,6 +213,8 @@ class PluginsRegistry:
# Check if the registry needs to be loaded
self.check_reload()
mixin = str(mixin).lower().strip()
result = []
for plugin in self.plugins.values():
@ -841,7 +854,7 @@ class PluginsRegistry:
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
return False

View File

@ -33,7 +33,7 @@ def mixin_enabled(plugin, key, *args, **kwargs):
@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."""
return len(registry.with_mixin(mixin)) > 0

View File

@ -3,14 +3,7 @@
from django.contrib import admin
from .helpers import report_model_options
from .models import (
LabelOutput,
LabelTemplate,
ReportAsset,
ReportOutput,
ReportSnippet,
ReportTemplate,
)
from .models import LabelTemplate, ReportAsset, ReportSnippet, ReportTemplate
@admin.register(LabelTemplate)
@ -42,11 +35,3 @@ class ReportAssetAdmin(admin.ModelAdmin):
"""Admin class for the ReportAsset model."""
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.models
import report.serializers
from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.filters import InvenTreeOrderingFilter, InvenTreeSearchFilter
from InvenTree.mixins import ListAPI, ListCreateAPI, RetrieveUpdateDestroyAPI
from common.models import DataOutput
from common.serializers import DataOutputSerializer
from InvenTree.api import MetadataView
from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin
@ -161,8 +163,7 @@ class LabelPrint(GenericAPIView):
if plugin_serializer:
kwargs['plugin_serializer'] = plugin_serializer
serializer = super().get_serializer(*args, **kwargs)
return serializer
return super().get_serializer(*args, **kwargs)
@method_decorator(never_cache)
def post(self, request, *args, **kwargs):
@ -202,14 +203,17 @@ class LabelPrint(GenericAPIView):
):
plugin_serializer.is_valid(raise_exception=True)
# Generate a new LabelOutput object to print against
output = report.models.LabelOutput.objects.create(
template=template,
plugin=plugin.slug,
user=request.user,
user = getattr(request, 'user', None)
# Generate a new DataOutput object to print against
output = DataOutput.objects.create(
user=user if user and user.is_authenticated else None,
total=len(items_to_print),
progress=0,
items=len(items_to_print),
complete=False,
output_type=DataOutput.DataOutputTypes.LABEL,
plugin=plugin.slug,
template_name=template.name,
output=None,
)
@ -224,9 +228,7 @@ class LabelPrint(GenericAPIView):
output.refresh_from_db()
return Response(
report.serializers.LabelOutputSerializer(output).data, status=201
)
return Response(DataOutputSerializer(output).data, status=201)
class LabelTemplateList(TemplatePermissionMixin, ListCreateAPI):
@ -274,18 +276,21 @@ class ReportPrint(GenericAPIView):
"""Print this report template against a number of provided items.
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
from InvenTree.tasks import offload_task
# Generate a new ReportOutput object
output = report.models.ReportOutput.objects.create(
template=template,
user=request.user,
user = getattr(request, 'user', None)
# Generate a new DataOutput object
output = DataOutput.objects.create(
user=user if user and user.is_authenticated else None,
total=len(items_to_print),
progress=0,
items=len(items_to_print),
complete=False,
output_type=DataOutput.DataOutputTypes.REPORT,
template_name=template.name,
output=None,
)
@ -296,9 +301,7 @@ class ReportPrint(GenericAPIView):
output.refresh_from_db()
return Response(
report.serializers.ReportOutputSerializer(output).data, status=201
)
return Response(DataOutputSerializer(output).data, status=201)
class ReportTemplateList(TemplatePermissionMixin, ListCreateAPI):
@ -347,44 +350,6 @@ class ReportAssetDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI):
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 = [
# Printing endpoint
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'),
]),
),
# 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 = [
@ -449,18 +404,6 @@ report_api_urls = [
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
path(
'asset/',

View File

@ -36,6 +36,8 @@ class ReportConfig(AppConfig):
super().ready()
self.cleanup()
# skip loading if plugin registry is not loaded or we run in a background thread
if (
not InvenTree.ready.isPluginRegistryLoaded()
@ -62,6 +64,15 @@ class ReportConfig(AppConfig):
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:
"""Construct a new ContentFile from a template file."""
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
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
@ -25,10 +24,11 @@ import InvenTree.helpers
import InvenTree.models
import report.helpers
import report.validators
from common.models import DataOutput
from common.settings import get_global_setting
from InvenTree.helpers_model import get_base_url
from InvenTree.models import MetadataMixin
from plugin import InvenTreePlugin
from plugin import InvenTreePlugin, PluginMixinEnum
from plugin.registry import registry
try:
@ -338,7 +338,7 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
}
# 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:
plugin.add_report_context(self, instance, request, context)
except Exception:
@ -348,16 +348,16 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
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.
Arguments:
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)
Returns:
output: The ReportOutput object representing the generated report(s)
output: The DataOutput object representing the generated report(s)
Raises:
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:
- 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 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)
@ -381,18 +379,19 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
# Start with a default report name
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:
output = ReportOutput.objects.create(
template=self,
items=len(items),
output = DataOutput.objects.create(
total=len(items),
user=request.user
if request and request.user and request.user.is_authenticated
else None,
progress=0,
complete=False,
output_type=DataOutput.DataOutputTypes.REPORT,
template_name=self.name,
output=None,
)
@ -547,7 +546,7 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
context['page_style'] = self.generate_page_style()
# Pass the context through to any registered plugins
plugins = registry.with_mixin('report')
plugins = registry.with_mixin(PluginMixinEnum.REPORT)
for plugin in plugins:
# Let each plugin add its own context data
@ -568,18 +567,18 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
options=None,
request=None,
**kwargs,
) -> 'LabelOutput':
) -> DataOutput:
"""Print labels for a list of items against this template.
Arguments:
items: A list of items to print labels for (model instance)
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)
request: The request object (optional)
Returns:
output: The LabelOutput object representing the generated label(s)
output: The DataOutput object representing the generated label(s)
Raises:
ValidationError: If there is an error during label printing
@ -592,15 +591,17 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
)
if not output:
output = LabelOutput.objects.create(
template=self,
items=len(items),
plugin=plugin.slug,
output = DataOutput.objects.create(
user=request.user
if request and request.user.is_authenticated
else None,
total=len(items),
progress=0,
complete=False,
output_type=DataOutput.DataOutputTypes.LABEL,
template_name=self.name,
plugin=plugin.slug,
output=None,
)
if options is None:
@ -628,81 +629,6 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
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):
"""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,
InvenTreeModelSerializer,
)
from users.serializers import UserSerializer
class ReportSerializerBase(InvenTreeModelSerializer):
@ -161,59 +160,6 @@ class LabelTemplateSerializer(ReportSerializerBase):
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):
"""Serializer class for the ReportSnippet model."""

View File

@ -1,43 +1,29 @@
"""Background tasks for the report app."""
from datetime import timedelta
import structlog
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')
@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):
"""Print multiple reports against the provided template.
Arguments:
template_id: The ID of the ReportTemplate to use
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,
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:
template = ReportTemplate.objects.get(pk=template_id)
output = ReportOutput.objects.get(pk=output_id)
output = DataOutput.objects.get(pk=output_id)
except Exception:
log_error('report.tasks.print_reports')
return
@ -57,18 +43,19 @@ def print_labels(
Arguments:
template_id: The ID of the LabelTemplate to use
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)
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 report.models import LabelOutput, LabelTemplate
from report.models import LabelTemplate
try:
template = LabelTemplate.objects.get(pk=template_id)
output = LabelOutput.objects.get(pk=output_id)
output = DataOutput.objects.get(pk=output_id)
except Exception:
log_error('report.tasks.print_labels')
return

View File

@ -290,7 +290,7 @@ class ReportTest(InvenTreeAPITestCase):
output = template.print(items)
self.assertTrue(output.complete)
self.assertEqual(output.items, 5)
self.assertEqual(output.total, 5)
self.assertIsNotNone(output.output)
self.assertTrue(output.output.name.endswith('.pdf'))
@ -341,8 +341,9 @@ class LabelTest(InvenTreeAPITestCase):
output = template.print(items=parts, plugin=plugin)
self.assertTrue(output.complete)
self.assertEqual(output.items, 10)
self.assertEqual(output.total, 10)
self.assertIsNotNone(output.output)
self.assertEqual(output.plugin, 'inventreelabel')
self.assertTrue(output.output.name.endswith('.pdf'))
@ -461,7 +462,7 @@ class TestReportTest(PrintTestMixins, ReportTest):
)
# 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'))
# 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 company.models import Company, SupplierPart
from company.serializers import CompanySerializer
from data_exporter.mixins import DataExportViewMixin
from generic.states.api import StatusView
from importer.mixins import DataExportViewMixin
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.filters import (
ORDER_FILTER_ALIAS,
@ -356,7 +356,7 @@ class StockLocationMixin:
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for the StockLocationList endpoint."""
@ -952,7 +952,7 @@ class StockApiMixin:
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView):
@ -1253,7 +1253,7 @@ class StockItemTestResultMixin:
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
class StockItemTestResultDetail(StockItemTestResultMixin, RetrieveUpdateDestroyAPI):
@ -1394,7 +1394,7 @@ class StockTrackingList(DataExportViewMixin, ListAPI):
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:
"""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.
"""
# First, check if any plugins can generate batch codes
from plugin.registry import registry
from plugin import PluginMixinEnum, registry
now = InvenTree.helpers.current_time()
@ -38,7 +38,7 @@ def generate_batch_code(**kwargs):
**kwargs,
}
for plugin in registry.with_mixin('validation'):
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
generate = getattr(plugin, 'generate_batch_code', None)
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.
"""
from plugin.registry import registry
from plugin import PluginMixinEnum, registry
# 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
for plugin in registry.with_mixin('validation'):
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
serial_int = plugin.convert_serial_to_int(serial)
except Exception:
@ -721,9 +721,9 @@ class StockItem(
- Validation is performed by custom plugins.
- 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:
plugin.validate_batch_code(self.batch, self)
except ValidationError as exc:

View File

@ -28,8 +28,8 @@ import stock.filters
import stock.status_codes
from common.settings import get_global_setting
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
from users.serializers import UserSerializer

View File

@ -1,17 +1,14 @@
"""Unit testing for the Stock API."""
import io
import os
import random
from datetime import datetime, timedelta
from enum import IntEnum
import django.http
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.urls import reverse
import tablib
from djmoney.money import Money
from rest_framework import status
@ -839,34 +836,9 @@ class StockItemListTest(StockAPITestCase):
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):
"""Test exporting of Stock data via the API."""
dataset = self.export_data({})
# Check that *all* stock item objects have been exported
self.assertEqual(len(dataset), StockItem.objects.count())
# Expected headers
headers = [
required_headers = [
'Part',
'Customer',
'Stock Location',
@ -881,45 +853,40 @@ class StockItemListTest(StockAPITestCase):
'Supplier Part.MPN',
]
for h in headers:
self.assertIn(h, dataset.headers)
excluded_headers = ['metadata']
for h in excluded_headers:
self.assertNotIn(h, dataset.headers)
with self.export_data(self.list_url) as data_file:
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
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')
item = StockItem.objects.get(pk=item_id)
for row in dataset:
item_id = int(row[idx_id])
item = StockItem.objects.get(pk=item_id)
# Location should match ID
self.assertEqual(loc_id, item.location.pk)
loc_id = int(row[idx_loc])
# Location name should match
self.assertEqual(row['Location.Name'], item.location.name)
# Location should match ID
self.assertEqual(int(loc_id), item.location.pk)
# Location name should match
loc_name = row[idx_loc_name]
self.assertEqual(loc_name, item.location.name)
# Part name should match
part_name = row[idx_part_name]
self.assertEqual(part_name, item.part.name)
# Part name should match
self.assertEqual(row['Part.Name'], item.part.name)
# Export stock items with a specific part
dataset = self.export_data({'part': 25})
self.assertEqual(len(dataset), 17)
with self.export_data(self.list_url, {'part': 25}) as data_file:
self.process_csv(data_file, required_rows=17)
def test_filter_by_allocated(self):
"""Test that we can filter by "allocated" status.

View File

@ -190,7 +190,8 @@ class GroupMixin:
params.get('permission_detail', None)
)
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
return super().get_serializer(*args, **kwargs)
class GroupDetail(GroupMixin, RetrieveUpdateDestroyAPI):

View File

@ -222,10 +222,8 @@ class RuleSet(models.Model):
'auth_permission',
'users_apitoken',
'users_ruleset',
'report_labeloutput',
'report_labeltemplate',
'report_reportasset',
'report_reportoutput',
'report_reportsnippet',
'report_reporttemplate',
'account_emailaddress',
@ -336,6 +334,7 @@ class RuleSet(models.Model):
# Models which currently do not require permissions
'common_attachment',
'common_customunit',
'common_dataoutput',
'common_inventreesetting',
'common_inventreeusersetting',
'common_notificationentry',

View File

@ -1,19 +1,12 @@
import { t } from '@lingui/macro';
import { notifications, showNotification } from '@mantine/notifications';
import {
IconCircleCheck,
IconPrinter,
IconReport,
IconTags
} from '@tabler/icons-react';
import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { api } from '../../App';
import { useApi } from '../../contexts/ApiContext';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import type { ModelType } from '../../enums/ModelType';
import { extractAvailableFields } from '../../functions/forms';
import { generateUrl } from '../../functions/urls';
import useDataOutput from '../../hooks/UseDataOutput';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { apiUrl } from '../../states/ApiState';
import {
@ -22,94 +15,6 @@ import {
} from '../../states/SettingsState';
import type { ApiFormFieldSet } from '../forms/fields/ApiFormField';
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({
items,
@ -142,16 +47,14 @@ export function PrintingActions({
const [labelId, setLabelId] = useState<number | undefined>(undefined);
const [reportId, setReportId] = useState<number | undefined>(undefined);
const labelProgress = usePrintingProgress({
const labelProgress = useDataOutput({
title: t`Printing Labels`,
outputId: labelId,
endpoint: ApiEndpoints.label_output
id: labelId
});
const reportProgress = usePrintingProgress({
const reportProgress = useDataOutput({
title: t`Printing Reports`,
outputId: reportId,
endpoint: ApiEndpoints.report_output
id: reportId
});
// Fetch available printing fields via OPTIONS request

View File

@ -21,12 +21,12 @@ import {
IconCalendarMonth,
IconChevronLeft,
IconChevronRight,
IconDownload,
IconFilter
} from '@tabler/icons-react';
import { useCallback, useState } from 'react';
import type { CalendarState } from '../../hooks/UseCalendar';
import { useLocalState } from '../../states/LocalState';
import { DownloadAction } from '../../tables/DownloadAction';
import type { TableFilter } from '../../tables/Filter';
import { FilterSelectDrawer } from '../../tables/FilterSelectDrawer';
import { TableSearchInput } from '../../tables/Search';
@ -35,7 +35,6 @@ import { ActionButton } from '../buttons/ActionButton';
import { StylishText } from '../items/StylishText';
export interface InvenTreeCalendarProps extends CalendarOptions {
downloadData?: (fileFormat: string) => void;
enableDownload?: boolean;
enableFilters?: boolean;
enableSearch?: boolean;
@ -45,7 +44,6 @@ export interface InvenTreeCalendarProps extends CalendarOptions {
}
export default function Calendar({
downloadData,
enableDownload,
enableFilters = false,
enableSearch,
@ -88,6 +86,7 @@ export default function Calendar({
return (
<>
{state.exportModal.modal}
{enableFilters && filters && (filters?.length ?? 0) > 0 && (
<Boundary label={`InvenTreeCalendarFilterDrawer-${state.name}`}>
<FilterSelectDrawer
@ -154,7 +153,7 @@ export default function Calendar({
variant='transparent'
aria-label='calendar-select-filters'
>
<Tooltip label={t`Calendar Filters`}>
<Tooltip label={t`Calendar Filters`} position='top-end'>
<IconFilter
onClick={() => setFiltersVisible(!filtersVisible)}
/>
@ -163,10 +162,14 @@ export default function Calendar({
</Indicator>
)}
{enableDownload && (
<DownloadAction
key='download-action'
downloadCallback={downloadData}
/>
<ActionIcon
variant='transparent'
aria-label='calendar-export-data'
>
<Tooltip label={t`Download data`} position='top-end'>
<IconDownload onClick={state.exportModal.open} />
</Tooltip>
</ActionIcon>
)}
</Group>
</Group>

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