diff --git a/docs/docs/plugins/mixins/report.md b/docs/docs/plugins/mixins/report.md
index 09e6e9d89a..d424ce7155 100644
--- a/docs/docs/plugins/mixins/report.md
+++ b/docs/docs/plugins/mixins/report.md
@@ -4,15 +4,57 @@ 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, and can also receive a callback when a report is generated.
### Add Report Context
A plugin which implements the ReportMixin mixin can define the `add_report_context` method, allowing custom context data to be added to a report template at time of printing.
+This method is called each time a report is generated, and is passed the following arguments:
+
+| Argument | Description |
+| --- | --- |
+| `report_instance` | The report template instance which is being rendered |
+| `model_instance` | The model instance against which the report is being generated |
+| `user` | The user who initiated the report generation |
+| `context` | The context dictionary, which can be modified in-place |
+
+Any data added to the provided `context` dictionary is made available to the report template, and can be rendered using standard django template syntax:
+
+```python
+def add_report_context(self, report_instance, model_instance, user, context):
+ """Add extra context data to the report template."""
+ context['my_custom_data'] = self.calculate_custom_data(model_instance)
+```
+
### Add Label Context
-Additionally the `add_label_context` method, allowing custom context data to be added to a label template at time of printing.
+Similarly, the `add_label_context` method allows custom context data to be added to a label template at time of printing:
+
+| Argument | Description |
+| --- | --- |
+| `label_instance` | The label template instance which is being rendered |
+| `model_instance` | The model instance against which the label is being generated |
+| `user` | The user who initiated the label generation |
+| `context` | The context dictionary, which can be modified in-place |
+
+### Report Callback
+
+The `report_callback` method is called after a report has been generated, and allows the plugin to perform custom actions with the generated report - for example, forwarding the report to an external system, or performing custom post-processing.
+
+| Argument | Description |
+| --- | --- |
+| `template` | The report template instance which was used to generate the report |
+| `instance` | The model instance against which the report was generated |
+| `report` | The generated report (PDF file data) |
+| `user` | The user who initiated the report generation |
+
+```python
+def report_callback(self, template, instance, report, user, **kwargs):
+ """Custom callback function - called after a report is generated."""
+ # For example, forward the generated report to an external service
+ self.upload_to_external_service(report)
+```
### Sample Plugin
diff --git a/docs/docs/report/assets.md b/docs/docs/report/assets.md
new file mode 100644
index 0000000000..176209634a
--- /dev/null
+++ b/docs/docs/report/assets.md
@@ -0,0 +1,45 @@
+---
+title: Report Assets
+---
+
+## Report Assets
+
+Users can upload asset files (e.g. images) which can be used when generating reports. For example, you may wish to generate a report with your company logo in the header.
+
+Asset files are managed from the [Admin Center](../settings/admin.md#admin-center), via the *Report Assets* panel. Staff users can upload new asset files, and remove assets which are no longer required.
+
+Asset files can be rendered directly into the template as follows
+
+```html
+{% raw %}
+
+{% load report %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endraw %}
+```
+
+!!! warning "Asset Naming"
+ If the requested asset name does not match the name of an uploaded asset, the template will continue without loading the image.
+
+!!! info "Assets location"
+ Upload new assets via the *Report Assets* panel in the [Admin Center](../settings/admin.md#admin-center) to ensure they are uploaded to the correct location on the server.
+
+There are various [helper functions](./helpers.md#report-assets) available to assist with embedding assets into templates.
diff --git a/docs/docs/report/context_variables.md b/docs/docs/report/context_variables.md
index e37891b711..7fc365fda1 100644
--- a/docs/docs/report/context_variables.md
+++ b/docs/docs/report/context_variables.md
@@ -69,12 +69,14 @@ Templates (whether for generating [reports](./report.md) or [labels](./labels.md
| Model Type | Description |
| --- | --- |
-| company | A Company instance |
+| [company](#company) | A Company instance |
| [build](#build-order) | A [Build Order](../manufacturing/build.md) instance |
| [buildline](#build-line) | A [Build Order Line Item](../manufacturing/build.md) instance |
| [salesorder](#sales-order) | A [Sales Order](../sales/sales_order.md) instance |
+| [salesordershipment](#sales-order-shipment) | A [Sales Order Shipment](../sales/sales_order.md#sales-order-shipments) instance |
| [returnorder](#return-order) | A [Return Order](../sales/return_order.md) instance |
| [purchaseorder](#purchase-order) | A [Purchase Order](../purchasing/purchase_order.md) instance |
+| [transferorder](#transfer-order) | A [Transfer Order](../stock/transfer_order.md) instance |
| [stockitem](#stock-item) | A [StockItem](../stock/index.md#stock-item) instance |
| [stocklocation](#stock-location) | A [StockLocation](../stock/index.md#stock-location) instance |
| [part](#part) | A [Part](../part/index.md) instance |
@@ -141,6 +143,16 @@ When printing a report or label against a [PurchaseOrder](../purchasing/purchase
{{ report_context("models", "purchaseorder") }}
+### Transfer Order
+
+When printing a report or label against a [TransferOrder](../stock/transfer_order.md) object, the following context variables are available:
+
+{{ report_context("models", "transferorder") }}
+
+::: order.models.TransferOrder.report_context
+ options:
+ show_source: True
+
### Stock Item
When printing a report or label against a [StockItem](../stock/index.md#stock-item) object, the following context variables are available:
@@ -173,7 +185,7 @@ When printing a report or label against a [Part](../part/index.md) object, the f
## Model Variables
-Additional to the context variables provided directly to each template, each model type has a number of attributes and methods which can be accessedd via the template.
+Additional to the context variables provided directly to each template, each model type has a number of attributes and methods which can be accessed via the template.
For each model type, a subset of the most commonly used attributes are listed below. For a full list of attributes and methods, refer to the source code for the particular model type.
@@ -187,7 +199,6 @@ Each part object has access to a lot of context variables about the part. The fo
|----------|-------------|
| name | Brief name for this part |
| full_name | Full name for this part (including IPN, if not null and including variant, if not null) |
-| variant | Optional variant number for this part - Must be unique for the part name
| category | The [PartCategory](#part-category) object to which this part belongs
| description | Longer form description of the part
| keywords | Optional keywords for improving part search results
@@ -244,7 +255,6 @@ Each part object has access to a lot of context variables about the part. The fo
| Variable | Description |
|----------|-------------|
| parent | Link to another [StockItem](#stock-item) from which this StockItem was created |
-| uid | Field containing a unique-id which is mapped to a third-party identifier (e.g. a barcode) |
| part | Link to the master abstract [Part](#part) that this [StockItem](#stock-item) is an instance of |
| supplier_part | Link to a specific [SupplierPart](#supplierpart) (optional) |
| location | The [StockLocation](#stock-location) Where this [StockItem](#stock-item) is located |
@@ -263,7 +273,6 @@ Each part object has access to a lot of context variables about the part. The fo
| build | Link to a Build (if this stock item was created from a build) |
| is_building | Boolean field indicating if this stock item is currently being built (or is "in production") |
| purchase_order | Link to a [PurchaseOrder](#purchase-order) (if this stock item was created from a PurchaseOrder) |
-| infinite | If True this [StockItem](#stock-item) can never be exhausted |
| sales_order | Link to a [SalesOrder](#sales-order) object (if the StockItem has been assigned to a SalesOrder) |
| purchase_price | The unit purchase price for this [StockItem](#stock-item) - this is the unit price at time of purchase (if this item was purchased from an external supplier) |
| packaging | Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc) |
@@ -353,7 +362,7 @@ Each part object has access to a lot of context variables about the part. The fo
| Variable | Description |
|----------|-------------|
| username | the username of the user |
-| fist_name | The first name of the user |
+| first_name | The first name of the user |
| last_name | The last name of the user |
| email | The email address of the user |
| pk | The primary key of the user |
diff --git a/docs/docs/report/helpers.md b/docs/docs/report/helpers.md
index 1cde507797..dbbe7306ad 100644
--- a/docs/docs/report/helpers.md
+++ b/docs/docs/report/helpers.md
@@ -907,7 +907,7 @@ If you have a custom logo, but explicitly wish to load the InvenTree logo itself
## Report Assets
-[Report Assets](./index.md#report-assets) are files specifically uploaded by the user for inclusion in generated reports and labels.
+[Report Assets](./assets.md) are files specifically uploaded by the user for inclusion in generated reports and labels.
You can add asset images to the reports and labels by using the `{% raw %}{% asset ... %}{% endraw %}` template tag:
diff --git a/docs/docs/report/index.md b/docs/docs/report/index.md
index 37124e13d6..39fafdacc0 100644
--- a/docs/docs/report/index.md
+++ b/docs/docs/report/index.md
@@ -53,29 +53,43 @@ To read more about the capabilities of the report templating engine, and how to
## Creating Templates
-Report and label templates can be created (and edited) via the [admin interface](../settings/admin.md), under the *Report* section.
+Report and label templates are managed from the [Admin Center](../settings/admin.md#admin-center), which provides dedicated panels (under the *Reporting* group) for each template type:
-Select the type of template you are wanting to create (a *Report Template* or *Label Template*) and press the *Add* button in the top right corner:
+- **Label Templates** - Create and edit [label templates](./labels.md)
+- **Report Templates** - Create and edit [report templates](./report.md)
+- **Report Snippets** - Manage reusable [snippet](#report-snippets) files
+- **Report Assets** - Manage uploaded [asset](#report-assets) files
-{{ image("report/report_template_admin.png", "Report template admin") }}
+Label and report templates are created and edited using the built-in [template editor](./template_editor.md), which allows templates to be written directly within the browser, with a live preview of the rendered output.
!!! tip "Staff Access Only"
- Only users with staff access can upload or edit report template files.
+ Only users with staff access can create, upload or edit templates, snippets and assets.
-!!! info "Editing Reports"
- Existing reports can be edited from the admin interface, in the same location as described above. To change the contents of the template, re-upload a template file, to override the existing template data.
-
-!!! tip "Template Editor"
- InvenTree also provides a powerful [template editor](./template_editor.md) which allows for the creation and editing of report templates directly within the browser.
+!!! info "Backend Admin Interface"
+ Templates can also be managed at a lower level via the [backend admin interface](../settings/admin.md#backend-admin-interface), under the *Report* section. This is recommended for advanced users only.
### Name and Description
Each report template requires a name and description, which identify and describe the report template.
+### Revision
+
+Each template has a revision number, which is automatically incremented each time the template is updated. This provides a simple mechanism for tracking changes to a template over time. The revision number is read-only, and cannot be edited directly.
+
+!!! info "Template Revision Context"
+ The revision number of the template is made available when rendering, via the `template_revision` [context variable](./context_variables.md#global-context).
+
### Enabled Status
Boolean field which determines if the specific report template is enabled, and available for use. Reports can be disabled to remove them from the list of available templates, but without deleting them from the database.
+### Attach to Model
+
+If the *Attach to Model on Print* option is enabled, a copy of the generated report is automatically saved as a file attachment against the item (model instance) for which it was generated, each time the template is printed.
+
+!!! warning "Attachment Support"
+ The report output is only attached if the target model type supports file attachments.
+
### Filename Pattern
The filename pattern used to generate the output `.pdf` file. Defaults to "report.pdf".
@@ -147,89 +161,15 @@ Setting the *Debug Mode* option renders the template as raw HTML instead of PDF,
## Report Assets
-User can upload asset files (e.g. images) which can be used when generating reports. For example, you may wish to generate a report with your company logo in the header. Asset files are uploaded via the admin interface.
-
-Asset files can be rendered directly into the template as follows
-
-```html
-{% raw %}
-
-{% load report %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-{% endraw %}
-```
-
-!!! warning "Asset Naming"
- If the requested asset name does not match the name of an uploaded asset, the template will continue without loading the image.
-
-!!! info "Assets location"
- Upload new assets via the [admin interface](../settings/admin.md) to ensure they are uploaded to the correct location on the server.
+User can upload asset files (e.g. images) which can be used when generating reports. For example, you may wish to generate a report with your company logo in the header.
+Refer to the [report assets](./assets.md) documentation for further information.
## Report Snippets
-A powerful feature provided by the django / WeasyPrint templating framework is the ability to include external template files. This allows commonly used template features to be broken out into separate files and reused across multiple templates.
+InvenTree provides report "snippets" - reusable template files which cannot be rendered by themselves, but can be included in other templates.
-To support this, InvenTree provides report "snippets" - short (or not so short) template files which cannot be rendered by themselves, but can be called from other templates.
-
-Similar to assets files, snippet template files are uploaded via the admin interface.
-
-Snippets are included in a template as follows:
-
-```
-{% raw %}{% include 'snippets/' %}{% endraw %}
-```
-
-For example, consider a custom stocktake report for a particular stock location, where we wish to render a table with a row for each item in that location.
-
-```html
-{% raw %}
-
-
-
-
-
-
- {% for item in location.stock_items %}
- {% include 'snippets/stock_row.html' with item=item %}
- {% endfor %}
-
-
-{% endraw %}
-```
-
-!!! info "Snippet Arguments"
- Note above that named argument variables can be passed through to the snippet!
-
-And the snippet file `stock_row.html` may be written as follows:
-
-```html
-{% raw %}
-
-
- | {{ item.part.full_name }} |
- {{ item.quantity }} |
-
-{% endraw %}
-```
+Refer to the [report snippets](./snippets.md) documentation for further information.
## Security
@@ -243,7 +183,7 @@ When WeasyPrint renders a template to PDF it can make outbound requests to load
|---|---|
| `data:` URIs | Always permitted — self-contained, no network access |
| `file://` | Always blocked — assets and images must be inlined as `data:` URIs before reaching WeasyPrint |
-| `http` / `https` | Disabled by default, but can be blocked - see *Remote URL Fetching* below |
+| `http` / `https` | Disabled by default, but can be enabled - see *Remote URL Fetching* below |
| Any other scheme | Always blocked |
HTTP redirects are also disabled: a URL that passes validation cannot redirect to an internal address.
@@ -259,6 +199,6 @@ When enabled, URLs are still validated against private, loopback, link-local, an
### Asset Files
-Asset files uploaded through the admin interface are embedded directly into the rendered PDF as base64 `data:` URIs — they are read via the Django storage API and never loaded through WeasyPrint's URL fetcher. This means assets work correctly regardless of whether remote URL fetching is enabled, and also work with remote storage backends such as S3.
+[Asset files](./assets.md) uploaded through the admin interface are embedded directly into the rendered PDF as base64 `data:` URIs — they are read via the Django storage API and never loaded through WeasyPrint's URL fetcher. This means assets work correctly regardless of whether remote URL fetching is enabled, and also work with remote storage backends such as S3.
There are various [helper functions](./helpers.md#report-assets) available to assist with embedding assets into templates.
diff --git a/docs/docs/report/labels.md b/docs/docs/report/labels.md
index 7b4ac1b768..cdaf7136d2 100644
--- a/docs/docs/report/labels.md
+++ b/docs/docs/report/labels.md
@@ -128,6 +128,25 @@ As an example, consider a label template for a StockItem. A user may wish to def
To restrict the label accordingly, we could set the *filters* value to `part__IPN=IPN123`.
+## Printing Labels
+
+Labels are printed directly from the web interface, from the pages where the target items are displayed. To print labels against one or more items:
+
+1. Select the items to print - either from a table (using the row checkboxes), or by viewing the detail page of a single item
+2. Select the *Print* action, and choose the *Print Label* option
+3. Select the desired label template - only *enabled* templates which match the selected model type (and pass any template filters) are available for selection
+4. Select the *printer* (plugin) to use for printing the labels
+
+### Label Printing Plugins
+
+The actual printing of labels is handled by a [label printing plugin](../plugins/mixins/label.md). InvenTree provides a number of built-in printing plugins:
+
+- The default [InvenTree Label Printer](../plugins/builtin/inventree_label.md) plugin generates a PDF file, which is then made available for download.
+- The [Label Sheet](../plugins/builtin/inventree_label_sheet.md) plugin arranges multiple labels onto a single sheet for printing.
+- The [Label Machine](../plugins/builtin/inventree_label_machine.md) plugin sends the label to an external [label printer machine](../plugins/machines/label_printer.md).
+
+Custom label printing plugins (e.g. for driving a specific hardware printer) can be installed to extend this list - refer to the [label mixin documentation](../plugins/mixins/label.md) for further information.
+
## Built-In Templates
The InvenTree installation provides a number of simple *default* templates which can be used as a starting point for creating custom labels. These built-in templates can be disabled if they are not required.
diff --git a/docs/docs/report/report.md b/docs/docs/report/report.md
index 935e977af9..47556e08ef 100644
--- a/docs/docs/report/report.md
+++ b/docs/docs/report/report.md
@@ -1,53 +1,51 @@
---
-title: Report and Label Generation
+title: Report Templates
---
-## Custom Reports
+## Report Templates
-InvenTree supports a customizable reporting ecosystem, allowing the user to develop document templates that meet their particular needs.
+Report templates are used to generate formal PDF documents - such as order reports, packing lists, or test reports - rendered against a particular database item (or list of items).
-PDF files are generated from custom HTML template files which are written by the user.
+Like all InvenTree templates, report templates are written using a mixture of HTML / CSS and the django template language, and are rendered to a PDF file using the WeasyPrint engine:
-Templates can be used to generate *reports* or *labels* which can be used in a variety of situations to format data in a friendly format for printing, distribution, conformance and testing.
+- Refer to the [template overview](./index.md) for information on creating and managing templates.
+- Refer to the [template rendering documentation](./weasyprint.md) for information on the WeasyPrint engine and the django template language.
+- Refer to the [context variables documentation](./context_variables.md) for the variables available when rendering a template.
-In addition to providing the ability for end-users to provide their own reporting templates, some report types offer "built-in" report templates ready for use.
+## Report Options
-### WeasyPrint Templates
+In addition to the [options common to all templates](./index.md#creating-templates), each report template provides the following options:
-InvenTree report templates utilize the powerful [WeasyPrint](https://weasyprint.org/) PDF generation engine.
+### Page Size
-!!! info "WeasyPrint"
- WeasyPrint is an extremely powerful and flexible reporting library. Refer to the [WeasyPrint docs](https://doc.courtbouillon.org/weasyprint/stable/) for further information.
+The page size (e.g. `A4` or `Letter`) used when rendering the report to a PDF file. When a new report template is created, this value defaults to the [default page size](./index.md#default-page-size) specified in the global settings.
-### Stylesheets
+### Landscape
-Templates are rendered using standard HTML / CSS - if you are familiar with web page layout, you're ready to go!
+If enabled, the report is rendered in landscape orientation, rather than the default portrait orientation.
-### Template Language
+### Merge
-Uploaded report template files are passed through the [django template rendering framework]({% include "django.html" %}/topics/templates/), and as such accept the same variable template strings as any other django template file. Different variables are passed to the report template (based on the context of the report) and can be used to customize the contents of the generated PDF.
+If enabled, a single report document is generated for all selected items, rather than a separate document for each item. Refer to the [merging reports](#merging-reports) section below for further information.
-### Variables
+!!! info "Context Variables"
+ The `page_size`, `landscape` and `merge` values are also made available to the template as [context variables](./context_variables.md#report-context).
-Each report template is provided a set of *context variables* which can be used when rendering the template.
+## Generating Reports
-For example, rendering the name of a part (which is available in the particular template context as `part`) is as follows:
+Reports are generated directly from the web interface, from the pages where the target items are displayed. To generate a report against one or more items:
-```html
-{% raw %}
+1. Select the items to print - either from a table (using the row checkboxes), or by viewing the detail page of a single item
+2. Select the *Print* action, and choose the *Print Report* option
+3. Select the desired report template - only *enabled* templates which match the selected model type (and pass any [template filters](./index.md#template-filters)) are available for selection
+4. The report is generated by the server, and the resulting PDF file is made available for download
-
-Part: {{ part.name }}
-
- Description:
- {{ part.description }}
-
-{% endraw %}
-```
+!!! info "Enable Reports"
+ Report generation must be [enabled in the global settings](./index.md#enable-reports) before reports can be generated.
## Merging Reports
-When rendering reports for multiple items, the default behaviour is that each item is rendered as a separate report. The chosen templeate is rendered multiple times, once for each item selected, and expects a single item in the context variable.
+When rendering reports for multiple items, the default behaviour is that each item is rendered as a separate report. The chosen template is rendered multiple times, once for each item selected, and expects a single item in the context variable.
However, it is possible to merge multiple items into a single report document. This is achieved by enabling the `merge` attribute of the report template:
diff --git a/docs/docs/report/samples.md b/docs/docs/report/samples.md
index 0489e3b92c..7fed4424ac 100644
--- a/docs/docs/report/samples.md
+++ b/docs/docs/report/samples.md
@@ -22,6 +22,7 @@ The following report templates are provided "out of the box" and can be used as
| [Sales Order Shipment](#sales-order-shipment) | [SalesOrderShipment](../sales/sales_order.md) | Sales Order Shipment report |
| [Stock Location](#stock-location) | [StockLocation](../stock/index.md#stock-location) | Stock Location report |
| [Test Report](#test-report) | [StockItem](../stock/index.md#stock-item) | Test Report |
+| [Transfer Order](#transfer-order) | [TransferOrder](../stock/transfer_order.md) | Transfer Order report |
| [Selected Stock Items Report](#selected-stock-items-report) | [StockItem](../stock/index.md#stock-item) | Selected Stock Items report |
@@ -57,6 +58,10 @@ The following report templates are provided "out of the box" and can be used as
{{ templatefile("report/inventree_test_report.html") }}
+### Transfer Order
+
+{{ templatefile("report/inventree_transfer_order_report.html") }}
+
### Selected Stock Items Report
{{ templatefile("report/inventree_stock_report_merge.html") }}
@@ -68,9 +73,11 @@ The following label templates are provided "out of the box" and can be used as a
| Template | Model Type | Description |
| --- | --- | --- |
| [Build Line](#build-line-label) | [Build line item](../manufacturing/build.md) | Build Line label |
-| [Part](#part-label) | [Part](../part/index.md) | Part label |
+| [Part](#part-label) | [Part](../part/index.md) | Part label (QR code and part name) |
+| [Part (Code128)](#part-label-code128) | [Part](../part/index.md) | Part label (Code128 barcode) |
| [Stock Item](#stock-item-label) | [StockItem](../stock/index.md#stock-item) | Stock Item label |
-| [Stock Location](#stock-location-label) | [StockLocation](../stock/index.md#stock-location) | Stock Location label |
+| [Stock Location](#stock-location-label) | [StockLocation](../stock/index.md#stock-location) | Stock Location label (QR code) |
+| [Stock Location (with text)](#stock-location-label-with-text) | [StockLocation](../stock/index.md#stock-location) | Stock Location label (QR code and location details) |
### Build Line Label
@@ -78,6 +85,10 @@ The following label templates are provided "out of the box" and can be used as a
### Part Label
+{{ templatefile("label/part_label.html") }}
+
+### Part Label (Code128)
+
{{ templatefile("label/part_label_code128.html") }}
### Stock Item Label
@@ -86,4 +97,8 @@ The following label templates are provided "out of the box" and can be used as a
### Stock Location Label
+{{ templatefile("label/stocklocation_qr.html") }}
+
+### Stock Location Label (with text)
+
{{ templatefile("label/stocklocation_qr_and_text.html") }}
diff --git a/docs/docs/report/snippets.md b/docs/docs/report/snippets.md
new file mode 100644
index 0000000000..6acf3f5eaa
--- /dev/null
+++ b/docs/docs/report/snippets.md
@@ -0,0 +1,52 @@
+---
+title: Report Snippets
+---
+
+## Report Snippets
+
+A powerful feature provided by the django / WeasyPrint templating framework is the ability to include external template files. This allows commonly used template features to be broken out into separate files and reused across multiple templates.
+
+To support this, InvenTree provides report "snippets" - short (or not so short) template files which cannot be rendered by themselves, but can be called from other templates.
+
+Snippet files are managed from the [Admin Center](../settings/admin.md#admin-center), via the *Report Snippets* panel. Staff users can upload new snippet files, and edit or remove existing snippets.
+
+Additionally, the content of an existing snippet can be modified directly within the browser - simply select a snippet from the table to open it in the built-in [template editor](./template_editor.md#editing-snippets).
+
+Snippets are included in a template as follows:
+
+```
+{% raw %}{% include 'snippets/' %}{% endraw %}
+```
+
+For example, consider a custom stocktake report for a particular stock location, where we wish to render a table with a row for each item in that location.
+
+```html
+{% raw %}
+
+
+
+
+
+
+ {% for item in location.stock_items %}
+ {% include 'snippets/stock_row.html' with item=item %}
+ {% endfor %}
+
+
+{% endraw %}
+```
+
+!!! info "Snippet Arguments"
+ Note above that named argument variables can be passed through to the snippet!
+
+And the snippet file `stock_row.html` may be written as follows:
+
+```html
+{% raw %}
+
+
+ | {{ item.part.full_name }} |
+ {{ item.quantity }} |
+
+{% endraw %}
+```
diff --git a/docs/docs/report/template_editor.md b/docs/docs/report/template_editor.md
index b4fbddbf04..3008434944 100644
--- a/docs/docs/report/template_editor.md
+++ b/docs/docs/report/template_editor.md
@@ -4,11 +4,11 @@ title: Template editor
## Template editor
-The Template Editor is integrated into the [Admin Center](../settings//admin.md#admin-center) of the Web UI. It allows users to create and edit label and report templates directly with a side by side preview for a more productive workflow.
+The Template Editor is integrated into the [Admin Center](../settings/admin.md#admin-center) of the Web UI. It allows users to create and edit label and report templates directly with a side by side preview for a more productive workflow.

-On the left side (1) are all possible possible template types for labels and reports listed. With the "+" button (2), above the template table (3), new templates for the selected type can be created. To switch to the template editor click on a template.
+On the left side (1) are all possible template types for labels and reports listed. With the "+" button (2), above the template table (3), new templates for the selected type can be created. To switch to the template editor click on a template.
### Editing Templates
@@ -31,3 +31,10 @@ If you don't want to override the template, but just render a preview for a temp
#### Edit template metadata
Editing metadata such as name, description, filters and even width/height for labels and orientation/page size for reports can be done from the edit modal accessible when clicking on the three dots (4) and select "Edit" in the dropdown menu.
+
+### Editing Snippets
+
+[Report snippets](./index.md#report-snippets) can also be edited directly within the browser, from the *Report Snippets* panel in the Admin Center. Selecting a snippet from the table opens it in the same code editor as used for report and label templates.
+
+!!! info "No Preview"
+ As snippets cannot be rendered by themselves (they must be included in a report or label template), no preview area is available when editing a snippet.
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index ca20e704c7..f24d8db82c 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -174,6 +174,8 @@ nav:
- Template Editor: report/template_editor.md
- Reports: report/report.md
- Labels: report/labels.md
+ - Report Assets: report/assets.md
+ - Report Snippets: report/snippets.md
- Context Variables: report/context_variables.md
- Helper Functions: report/helpers.md
- Barcodes: report/barcodes.md
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index b34c1ef5c5..952bd5fa63 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,11 +1,19 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 513
+INVENTREE_API_VERSION = 515
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v515 -> 2026-07-03 : https://github.com/inventree/InvenTree/pull/12298
+ - Change the file fields definition to binary (from uri) in the upload requests
+
+v514 -> 2026-07-02 : https://github.com/inventree/InvenTree/pull/12294
+ - Adds "duplicate" field to the BuildOrder, Company, ManufacturerPart, SupplierPart and SalesOrderShipment API endpoints
+ - Order duplication options: renames "order_id" field to "original", which now performs primary-key validation
+ - Part duplication options: renames "part" field to "original"
+
v513 -> 2026-06-25 : https://github.com/inventree/InvenTree/pull/12250
- Adds "active" field to the ProjectCode model and API endpoints
diff --git a/src/backend/InvenTree/InvenTree/fields.py b/src/backend/InvenTree/InvenTree/fields.py
index b7c09dbe12..1254b1b57d 100644
--- a/src/backend/InvenTree/InvenTree/fields.py
+++ b/src/backend/InvenTree/InvenTree/fields.py
@@ -1,6 +1,7 @@
"""Custom fields used in InvenTree."""
import sys
+import uuid
from decimal import Decimal
from django import forms
@@ -59,6 +60,34 @@ class InvenTreeURLField(models.URLField):
super().__init__(**kwargs)
+class InvenTreeUUIDField(models.UUIDField):
+ """UUIDField which is always stored as a char(32) column on MySQL / MariaDB.
+
+ On MariaDB 10.7+, Django maps UUIDField to the native 'uuid' column type,
+ and writes 36-character (hyphenated) values to match.
+ However, databases migrated under older Django / MariaDB versions retain
+ their original char(32) columns, into which a 36-character value does not fit.
+
+ To ensure consistent behavior across all supported database backends,
+ we force the legacy char(32) storage format on MySQL / MariaDB.
+
+ Ref: https://docs.djangoproject.com/en/5.2/releases/5.0/#migrating-existing-uuidfield-on-mariadb-10-7
+ """
+
+ def db_type(self, connection):
+ """Force a char(32) column type on MySQL / MariaDB backends."""
+ if connection.vendor == 'mysql':
+ return 'char(32)'
+ return super().db_type(connection)
+
+ def get_db_prep_value(self, value, connection, prepared=False):
+ """Store values in 32-character hex format on MySQL / MariaDB backends."""
+ value = super().get_db_prep_value(value, connection, prepared)
+ if connection.vendor == 'mysql' and isinstance(value, uuid.UUID):
+ value = value.hex
+ return value
+
+
def money_kwargs(**kwargs):
"""Returns the database settings for MoneyFields."""
from common.currency import currency_code_mappings
diff --git a/src/backend/InvenTree/InvenTree/schema.py b/src/backend/InvenTree/InvenTree/schema.py
index edea87bfef..ebd3bda9d8 100644
--- a/src/backend/InvenTree/InvenTree/schema.py
+++ b/src/backend/InvenTree/InvenTree/schema.py
@@ -16,6 +16,7 @@ from drf_spectacular.utils import (
extend_schema,
extend_schema_view,
)
+from rest_framework import serializers
from rest_framework.pagination import LimitOffsetPagination
from InvenTree.permissions import OASTokenMixin
@@ -46,6 +47,20 @@ class ExtendedOAuth2Scheme(DjangoOAuthToolkitScheme):
class ExtendedAutoSchema(AutoSchema):
"""Extend drf-spectacular to allow customizing the schema to match the actual API behavior."""
+ def _map_serializer_field(self, field, direction, *args, **kwargs):
+ """Custom field mapping overrides, falling back to default behavior."""
+ schema = super()._map_serializer_field(field, direction, *args, **kwargs)
+
+ direction_value = getattr(direction, 'value', direction)
+
+ # File and image fields in request schemas must be represented as binary
+ # payloads. In response schemas they are still rendered as URLs.
+ if direction_value == 'request' and isinstance(field, serializers.FileField):
+ schema['type'] = 'string'
+ schema['format'] = 'binary'
+
+ return schema
+
def is_bulk_action(self, ref: str) -> bool:
"""Check the class of the current view for the bulk mixins."""
return ref in [c.__name__ for c in type(self.view).__mro__]
diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py
index ba75e875d8..a187d73e51 100644
--- a/src/backend/InvenTree/InvenTree/serializers.py
+++ b/src/backend/InvenTree/InvenTree/serializers.py
@@ -600,7 +600,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
Default implementation returns an empty list
"""
- return []
+ return getattr(self, 'SKIP_CREATE_FIELDS', [])
def save(self, **kwargs):
"""Catch any django ValidationError thrown at the moment `save` is called, and re-throw as a DRF ValidationError."""
@@ -921,3 +921,104 @@ class ContentTypeField(serializers.ChoiceField):
)
return content_type
+
+
+class DuplicateOptionsSerializer(serializers.Serializer):
+ """Generic serializer for specifying copy options when duplicating a model instance.
+
+ Builds its fields dynamically at instantiation time so the same class can be
+ reused for any model without subclassing.
+ """
+
+ # Special 'shortcut' fields which are used for multiple models
+ DEFAULT_FIELDS = [
+ (
+ 'copy_parameters',
+ _('Copy Parameters'),
+ _('Copy parameters from the original item'),
+ False,
+ ),
+ (
+ 'copy_lines',
+ _('Copy Lines'),
+ _('Copy line items from the original order'),
+ False,
+ ),
+ (
+ 'copy_extra_lines',
+ _('Copy Extra Lines'),
+ _('Copy extra line items from the original order'),
+ False,
+ ),
+ ]
+
+ def __init__(
+ self,
+ queryset: QuerySet,
+ *args,
+ copy_fields: Optional[list[dict]] = None,
+ **kwargs,
+ ):
+ """Initialise the serializer and dynamically attach fields.
+
+ Arguments:
+ queryset: Queryset used for the `original` PrimaryKeyRelatedField.
+ copy_fields: Optional list of dicts, each describing one boolean copy toggle.
+ Keys:
+ name: (str, required)
+ label: (str, optional)
+ help_text: (str, optional)
+ default: (bool, optional, default True)
+ """
+ # Enforce certain properties onto this serializer
+ kwargs['label'] = kwargs.get('label', _('Duplication Options'))
+ kwargs['help_text'] = kwargs.get(
+ 'help_text', _('Specify options for duplicating this item')
+ )
+ kwargs['required'] = False
+ kwargs['write_only'] = True
+
+ copy_fields = copy_fields or []
+ copy_field_names = [spec['name'] for spec in copy_fields]
+
+ # Apply "default" fields
+ for name, label, help_text, default_value in self.DEFAULT_FIELDS:
+ popped_value = kwargs.pop(name, default_value)
+
+ if name in copy_field_names:
+ # Manually supplied field, continue
+ continue
+
+ if popped_value:
+ copy_fields.append({
+ 'name': name,
+ 'label': label,
+ 'help_text': help_text,
+ 'default': True,
+ })
+
+ super().__init__(*args, **kwargs)
+
+ # Re-class the instance with a model-specific subclass,
+ # so that each model generates a unique schema component name
+ if self.__class__ is DuplicateOptionsSerializer:
+ self.__class__ = type(
+ f'{queryset.model.__name__}DuplicateOptionsSerializer',
+ (DuplicateOptionsSerializer,),
+ {},
+ )
+
+ self.fields['original'] = serializers.PrimaryKeyRelatedField(
+ queryset=queryset,
+ required=True,
+ label=_('Original'),
+ help_text=_('Select instance to duplicate'),
+ )
+
+ for spec in copy_fields or []:
+ self.fields[spec['name']] = serializers.BooleanField(
+ required=False,
+ default=spec.get('default', True),
+ label=spec.get('label', spec['name']),
+ help_text=spec.get('help_text', ''),
+ )
diff --git a/src/backend/InvenTree/InvenTree/tests.py b/src/backend/InvenTree/InvenTree/tests.py
index ea2f6d4171..7934f91324 100644
--- a/src/backend/InvenTree/InvenTree/tests.py
+++ b/src/backend/InvenTree/InvenTree/tests.py
@@ -22,6 +22,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money
from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
+from rest_framework import serializers
from sesame.utils import get_user
from stdimage.models import StdImageFieldFile
@@ -1817,6 +1818,35 @@ class SchemaPostprocessingTest(TestCase):
# required key removed when empty
self.assertNotIn('required', schemas_out.get('SalesOrderShipment'))
+ def test_file_field_request_schema_binary(self):
+ """Verify only request file fields are exposed as binary."""
+ auto_schema = object.__new__(schema.ExtendedAutoSchema)
+
+ mapped_schemas = [
+ {'type': 'string', 'format': 'uri', 'nullable': True},
+ {'type': 'string', 'format': 'uri'},
+ {'type': 'string', 'format': 'uri', 'nullable': True},
+ ]
+
+ with mock.patch(
+ 'drf_spectacular.openapi.AutoSchema._map_serializer_field',
+ side_effect=mapped_schemas,
+ ):
+ file_request = auto_schema._map_serializer_field(
+ serializers.FileField(allow_null=True), 'request'
+ )
+ url_request = auto_schema._map_serializer_field(
+ serializers.URLField(), 'request'
+ )
+ file_response = auto_schema._map_serializer_field(
+ serializers.FileField(allow_null=True), 'response'
+ )
+
+ self.assertEqual(file_request['format'], 'binary')
+ self.assertTrue(file_request['nullable'])
+ self.assertEqual(url_request['format'], 'uri')
+ self.assertEqual(file_response['format'], 'uri')
+
class URLCompatibilityTest(InvenTreeTestCase):
"""Unit test for legacy URL compatibility."""
diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py
index a4bf2430fd..3864061b7d 100644
--- a/src/backend/InvenTree/build/serializers.py
+++ b/src/backend/InvenTree/build/serializers.py
@@ -34,6 +34,7 @@ from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.serializers import (
CustomStatusSerializerMixin,
+ DuplicateOptionsSerializer,
FilterableSerializerMixin,
InvenTreeDecimalField,
InvenTreeModelSerializer,
@@ -67,6 +68,8 @@ class BuildSerializer(
):
"""Serializes a Build object."""
+ SKIP_CREATE_FIELDS = ['duplicate']
+
class Meta:
"""Serializer metaclass."""
@@ -80,6 +83,7 @@ class BuildSerializer(
'completed',
'completion_date',
'destination',
+ 'duplicate',
'external',
'parent',
'part',
@@ -190,12 +194,29 @@ class BuildSerializer(
return queryset
+ duplicate = DuplicateOptionsSerializer(Build.objects.all(), copy_parameters=True)
+
def __init__(self, *args, **kwargs):
"""Determine if extra serializer fields are required."""
kwargs.pop('create', False)
super().__init__(*args, **kwargs)
+ @transaction.atomic
+ def create(self, validated_data):
+ """Create a new Build instance, optionally copying data from an existing build."""
+ duplicate = validated_data.pop('duplicate', None)
+
+ instance = super().create(validated_data)
+
+ if duplicate:
+ original = duplicate['original']
+
+ if duplicate.get('copy_parameters', True):
+ instance.copy_parameters_from(original)
+
+ return instance
+
def validate_reference(self, reference):
"""Custom validation for the Build reference field."""
# Ensure the reference matches the required pattern
diff --git a/src/backend/InvenTree/common/migrations/0046_alter_emailmessage_global_id_and_more.py b/src/backend/InvenTree/common/migrations/0046_alter_emailmessage_global_id_and_more.py
new file mode 100644
index 0000000000..5ead5a53bf
--- /dev/null
+++ b/src/backend/InvenTree/common/migrations/0046_alter_emailmessage_global_id_and_more.py
@@ -0,0 +1,59 @@
+"""Migration to store UUID primary keys as char(32) on MySQL / MariaDB.
+
+On MariaDB 10.7+, Django 5.x creates UUIDField columns with the native 'uuid'
+type and writes 36-character (hyphenated) values. Databases migrated under older
+Django / MariaDB versions retain char(32) columns, into which the new values
+do not fit.
+
+See: https://github.com/inventree/InvenTree/issues/12270
+"""
+
+import uuid
+
+from django.db import migrations
+
+import InvenTree.fields
+
+
+class Migration(migrations.Migration):
+ dependencies = [('common', '0045_projectcode_active')]
+
+ operations = [
+ migrations.AlterField(
+ model_name='emailmessage',
+ name='global_id',
+ field=InvenTree.fields.InvenTreeUUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ help_text='Unique identifier for this message',
+ primary_key=True,
+ serialize=False,
+ unique=True,
+ verbose_name='Global ID',
+ ),
+ ),
+ migrations.AlterField(
+ model_name='emailthread',
+ name='global_id',
+ field=InvenTree.fields.InvenTreeUUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ help_text='Unique identifier for this thread',
+ primary_key=True,
+ serialize=False,
+ verbose_name='Global ID',
+ ),
+ ),
+ migrations.AlterField(
+ model_name='webhookmessage',
+ name='message_id',
+ field=InvenTree.fields.InvenTreeUUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ help_text='Unique identifier for this message',
+ primary_key=True,
+ serialize=False,
+ verbose_name='Message ID',
+ ),
+ ),
+ ]
diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py
index b07dd08eeb..bb89a0622b 100644
--- a/src/backend/InvenTree/common/models.py
+++ b/src/backend/InvenTree/common/models.py
@@ -1584,7 +1584,7 @@ class WebhookMessage(models.Model):
worked_on: Was the work on this message finished?
"""
- message_id = models.UUIDField(
+ message_id = InvenTree.fields.InvenTreeUUIDField(
verbose_name=_('Message ID'),
help_text=_('Unique identifier for this message'),
primary_key=True,
@@ -3266,7 +3266,7 @@ class EmailMessage(models.Model):
TRACK_READ = 'track_read', _('Track Read')
TRACK_CLICK = 'track_click', _('Track Click')
- global_id = models.UUIDField(
+ global_id = InvenTree.fields.InvenTreeUUIDField(
verbose_name=_('Global ID'),
help_text=_('Unique identifier for this message'),
primary_key=True,
@@ -3374,7 +3374,7 @@ class EmailThread(InvenTree.models.InvenTreeMetadataModel):
blank=True,
help_text=_('Unique key for this thread (used to identify the thread)'),
)
- global_id = models.UUIDField(
+ global_id = InvenTree.fields.InvenTreeUUIDField(
verbose_name=_('Global ID'),
help_text=_('Unique identifier for this thread'),
primary_key=True,
diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py
index 1aa191e966..809f06710f 100644
--- a/src/backend/InvenTree/company/serializers.py
+++ b/src/backend/InvenTree/company/serializers.py
@@ -1,5 +1,6 @@
"""JSON serializers for Company app."""
+from django.db import transaction
from django.db.models import Prefetch
from django.utils.translation import gettext_lazy as _
@@ -14,6 +15,7 @@ from importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema
from InvenTree.serializers import (
+ DuplicateOptionsSerializer,
FilterableSerializerMixin,
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
@@ -118,6 +120,8 @@ class CompanySerializer(
import_exclude_fields = ['image']
+ SKIP_CREATE_FIELDS = ['duplicate']
+
class Meta:
"""Metaclass options."""
@@ -132,6 +136,7 @@ class CompanySerializer(
'email',
'currency',
'contact',
+ 'duplicate',
'link',
'image',
'active',
@@ -191,6 +196,23 @@ class CompanySerializer(
parameters = common.filters.enable_parameters_filter()
+ duplicate = DuplicateOptionsSerializer(Company.objects.all(), copy_parameters=True)
+
+ @transaction.atomic
+ def create(self, validated_data):
+ """Create a new Company instance, optionally copying data from an existing company."""
+ duplicate = validated_data.pop('duplicate', None)
+
+ instance = super().create(validated_data)
+
+ if duplicate:
+ original = duplicate['original']
+
+ if duplicate.get('copy_parameters', True):
+ instance.copy_parameters_from(original)
+
+ return instance
+
@register_importer()
class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
@@ -217,6 +239,8 @@ class ManufacturerPartSerializer(
):
"""Serializer for ManufacturerPart object."""
+ SKIP_CREATE_FIELDS = ['duplicate']
+
class Meta:
"""Metaclass options."""
@@ -229,6 +253,7 @@ class ManufacturerPartSerializer(
'manufacturer',
'manufacturer_detail',
'description',
+ 'duplicate',
'MPN',
'link',
'barcode_hash',
@@ -241,6 +266,25 @@ class ManufacturerPartSerializer(
parameters = common.filters.enable_parameters_filter()
+ duplicate = DuplicateOptionsSerializer(
+ ManufacturerPart.objects.all(), copy_parameters=True
+ )
+
+ @transaction.atomic
+ def create(self, validated_data):
+ """Create a new ManufacturerPart instance, optionally copying data from an existing instance."""
+ duplicate = validated_data.pop('duplicate', None)
+
+ instance = super().create(validated_data)
+
+ if duplicate:
+ original = duplicate['original']
+
+ if duplicate.get('copy_parameters', True):
+ instance.copy_parameters_from(original)
+
+ return instance
+
part_detail = OptionalField(
serializer_class=part_serializers.PartBriefSerializer,
serializer_kwargs={
@@ -323,6 +367,8 @@ class SupplierPartSerializer(
export_exclude_fields = ['tags']
+ SKIP_CREATE_FIELDS = ['duplicate']
+
export_child_fields = [
'part_detail.name',
'part_detail.description',
@@ -339,6 +385,7 @@ class SupplierPartSerializer(
'available',
'availability_updated',
'description',
+ 'duplicate',
'in_stock',
'on_order',
'link',
@@ -494,6 +541,10 @@ class SupplierPartSerializer(
# Date fields
updated = serializers.DateTimeField(allow_null=True, read_only=True)
+ duplicate = DuplicateOptionsSerializer(
+ SupplierPart.objects.all(), copy_parameters=True
+ )
+
@staticmethod
def annotate_queryset(queryset):
"""Annotate the SupplierPart queryset with extra fields.
@@ -522,8 +573,11 @@ class SupplierPartSerializer(
return response
+ @transaction.atomic
def create(self, validated_data):
"""Extract manufacturer data and process ManufacturerPart."""
+ duplicate = validated_data.pop('duplicate', None)
+
# Extract 'available' quantity from the serializer
available = validated_data.pop('available', None)
@@ -541,6 +595,12 @@ class SupplierPartSerializer(
kwargs = {'manufacturer': manufacturer, 'MPN': MPN}
supplier_part.save(**kwargs)
+ if duplicate:
+ original = duplicate['original']
+
+ if duplicate.get('copy_parameters', True):
+ supplier_part.copy_parameters_from(original)
+
return supplier_part
diff --git a/src/backend/InvenTree/data_exporter/mixins.py b/src/backend/InvenTree/data_exporter/mixins.py
index 0d58d0efff..af1b971db8 100644
--- a/src/backend/InvenTree/data_exporter/mixins.py
+++ b/src/backend/InvenTree/data_exporter/mixins.py
@@ -16,6 +16,7 @@ from taggit.serializers import TagListSerializerField
import data_exporter.serializers
import data_exporter.tasks
import InvenTree.exceptions
+import InvenTree.serializers
from common.models import DataOutput
from InvenTree.helpers import str2bool
from InvenTree.tasks import offload_task
@@ -64,6 +65,14 @@ class DataExportSerializerMixin:
# Exclude fields which are not required for data export
for field in self.get_export_exclude_fields(**kwargs):
self.fields.pop(field, None)
+
+ # Duplication options are never used for data export
+ for field in [
+ name
+ for name, field in self.fields.items()
+ if isinstance(field, InvenTree.serializers.DuplicateOptionsSerializer)
+ ]:
+ self.fields.pop(field, None)
else:
# Exclude fields which are only used for data export
for field in self.get_export_only_fields(**kwargs):
diff --git a/src/backend/InvenTree/importer/mixins.py b/src/backend/InvenTree/importer/mixins.py
index 969f82afa2..9cd5401606 100644
--- a/src/backend/InvenTree/importer/mixins.py
+++ b/src/backend/InvenTree/importer/mixins.py
@@ -3,6 +3,8 @@
from rest_framework import fields, serializers
from taggit.serializers import TagListSerializerField
+import InvenTree.serializers
+
class DataImportSerializerMixin:
"""Mixin class for adding data import functionality to a DRF serializer."""
@@ -41,6 +43,14 @@ class DataImportSerializerMixin:
for field in self.get_import_exclude_fields(**kwargs):
self.fields.pop(field, None)
+ # Duplication options are never used for data import
+ for field in [
+ name
+ for name, field in self.fields.items()
+ if isinstance(field, InvenTree.serializers.DuplicateOptionsSerializer)
+ ]:
+ self.fields.pop(field, None)
+
else:
# Exclude fields which are only used for data import
for field in self.get_import_only_fields(**kwargs):
diff --git a/src/backend/InvenTree/machine/migrations/0002_alter_machineconfig_id.py b/src/backend/InvenTree/machine/migrations/0002_alter_machineconfig_id.py
new file mode 100644
index 0000000000..5d3aac8bea
--- /dev/null
+++ b/src/backend/InvenTree/machine/migrations/0002_alter_machineconfig_id.py
@@ -0,0 +1,28 @@
+"""Migration to store MachineConfig UUID primary keys as char(32) on MySQL / MariaDB.
+
+On MariaDB 10.7+, Django 5.x writes 36-character (hyphenated) UUID values,
+but databases migrated under older Django / MariaDB versions retain char(32)
+columns, causing machine creation to fail with "Data too long for column 'id'".
+
+See: https://github.com/inventree/InvenTree/issues/12270
+"""
+
+import uuid
+
+from django.db import migrations
+
+import InvenTree.fields
+
+
+class Migration(migrations.Migration):
+ dependencies = [('machine', '0001_initial')]
+
+ operations = [
+ migrations.AlterField(
+ model_name='machineconfig',
+ name='id',
+ field=InvenTree.fields.InvenTreeUUIDField(
+ default=uuid.uuid4, editable=False, primary_key=True, serialize=False
+ ),
+ ),
+ ]
diff --git a/src/backend/InvenTree/machine/models.py b/src/backend/InvenTree/machine/models.py
index b81b1c7a00..f1f1896a85 100755
--- a/src/backend/InvenTree/machine/models.py
+++ b/src/backend/InvenTree/machine/models.py
@@ -10,6 +10,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
import common.models
+import InvenTree.fields
from machine import registry
from machine.machine_type import BaseMachineType
@@ -17,7 +18,9 @@ from machine.machine_type import BaseMachineType
class MachineConfig(models.Model):
"""A Machine objects represents a physical machine."""
- id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ id = InvenTree.fields.InvenTreeUUIDField(
+ primary_key=True, default=uuid.uuid4, editable=False
+ )
name = models.CharField(
unique=True,
diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py
index 7ef36f5609..04dc6e79f4 100644
--- a/src/backend/InvenTree/order/serializers.py
+++ b/src/backend/InvenTree/order/serializers.py
@@ -32,6 +32,7 @@ from InvenTree.helpers import extract_serial_numbers, hash_barcode, normalize, s
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.serializers import (
CustomStatusSerializerMixin,
+ DuplicateOptionsSerializer,
FilterableSerializerMixin,
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
@@ -67,40 +68,6 @@ class TotalPriceMixin(serializers.Serializer):
)
-class DuplicateOrderSerializer(serializers.Serializer):
- """Serializer for specifying options when duplicating an order."""
-
- class Meta:
- """Metaclass options."""
-
- fields = ['order_id', 'copy_lines', 'copy_extra_lines', 'copy_parameters']
-
- order_id = serializers.IntegerField(
- required=True, label=_('Order ID'), help_text=_('ID of the order to duplicate')
- )
-
- copy_lines = serializers.BooleanField(
- required=False,
- default=True,
- label=_('Copy Lines'),
- help_text=_('Copy line items from the original order'),
- )
-
- copy_extra_lines = serializers.BooleanField(
- required=False,
- default=True,
- label=_('Copy Extra Lines'),
- help_text=_('Copy extra line items from the original order'),
- )
-
- copy_parameters = serializers.BooleanField(
- required=False,
- default=True,
- label=_('Copy Parameters'),
- help_text=_('Copy order parameters from the original order'),
- )
-
-
class AbstractOrderSerializer(
CustomStatusSerializerMixin,
DataImportExportSerializerMixin,
@@ -110,9 +77,9 @@ class AbstractOrderSerializer(
):
"""Abstract serializer class which provides fields common to all order types."""
- export_exclude_fields = ['notes', 'duplicate']
+ export_exclude_fields = ['notes']
- import_exclude_fields = ['notes', 'duplicate']
+ import_exclude_fields = ['notes']
# Number of line items in this order
line_items = serializers.IntegerField(
@@ -195,12 +162,8 @@ class AbstractOrderSerializer(
created_by = UserSerializer(read_only=True)
- duplicate = DuplicateOrderSerializer(
- label=_('Duplicate Order'),
- help_text=_('Specify options for duplicating this order'),
- required=False,
- write_only=True,
- )
+ # Note: The 'duplicate' field must be defined by each concrete serializer class,
+ # as it requires a queryset specific to the order model type
def validate_reference(self, reference):
"""Custom validation for the reference field."""
@@ -293,30 +256,21 @@ class AbstractOrderSerializer(
instance = super().create(validated_data)
if duplicate:
- order_id = duplicate.get('order_id', None)
- copy_lines = duplicate.get('copy_lines', True)
- copy_extra_lines = duplicate.get('copy_extra_lines', True)
- copy_parameters = duplicate.get('copy_parameters', True)
+ original = duplicate['original']
- try:
- copy_from = instance.__class__.objects.get(pk=order_id)
- except Exception:
- # If the order ID is invalid, raise a validation error
- raise ValidationError(_('Invalid order ID'))
-
- if copy_lines:
- for line in copy_from.lines.all():
+ if duplicate.get('copy_lines', False):
+ for line in original.lines.all():
instance.clean_line_item(line)
line.save()
- if copy_extra_lines:
- for line in copy_from.extra_lines.all():
+ if duplicate.get('copy_extra_lines', False):
+ for line in original.extra_lines.all():
line.pk = None
line.order = instance
line.save()
- if copy_parameters:
- instance.copy_parameters_from(copy_from)
+ if duplicate.get('copy_parameters', False):
+ instance.copy_parameters_from(original)
return instance
@@ -452,6 +406,13 @@ class PurchaseOrderSerializer(
return [*fields, 'duplicate']
+ duplicate = DuplicateOptionsSerializer(
+ order.models.PurchaseOrder.objects.all(),
+ copy_lines=True,
+ copy_extra_lines=True,
+ copy_parameters=True,
+ )
+
@staticmethod
def annotate_queryset(queryset):
"""Add extra information to the queryset.
@@ -1134,6 +1095,13 @@ class SalesOrderSerializer(
return [*fields, 'duplicate']
+ duplicate = DuplicateOptionsSerializer(
+ order.models.SalesOrder.objects.all(),
+ copy_lines=True,
+ copy_extra_lines=True,
+ copy_parameters=True,
+ )
+
@staticmethod
def annotate_queryset(queryset):
"""Add extra information to the queryset.
@@ -1411,6 +1379,8 @@ class SalesOrderShipmentSerializer(
):
"""Serializer for the SalesOrderShipment class."""
+ SKIP_CREATE_FIELDS = ['duplicate']
+
class Meta:
"""Metaclass options."""
@@ -1423,6 +1393,7 @@ class SalesOrderShipmentSerializer(
'shipment_address',
'delivery_date',
'checked_by',
+ 'duplicate',
'reference',
'tracking_number',
'invoice_number',
@@ -1507,6 +1478,25 @@ class SalesOrderShipmentSerializer(
tags = common.filters.enable_tags_filter()
+ duplicate = DuplicateOptionsSerializer(
+ order.models.SalesOrderShipment.objects.all(), copy_parameters=True
+ )
+
+ @transaction.atomic
+ def create(self, validated_data):
+ """Create a new SalesOrderShipment instance, optionally copying data from an existing shipment."""
+ duplicate = validated_data.pop('duplicate', None)
+
+ instance = super().create(validated_data)
+
+ if duplicate:
+ original = duplicate['original']
+
+ if duplicate.get('copy_parameters', True):
+ instance.copy_parameters_from(original)
+
+ return instance
+
class SalesOrderAllocationSerializer(
FilterableSerializerMixin, InvenTreeModelSerializer
@@ -2198,6 +2188,14 @@ class ReturnOrderSerializer(
return [*fields, 'duplicate']
+ # Note: line items cannot be duplicated from a ReturnOrder,
+ # as they are linked to specific stock items
+ duplicate = DuplicateOptionsSerializer(
+ order.models.ReturnOrder.objects.all(),
+ copy_extra_lines=True,
+ copy_parameters=True,
+ )
+
@staticmethod
def annotate_queryset(queryset):
"""Custom annotation for the serializer queryset."""
@@ -2485,6 +2483,11 @@ class TransferOrderSerializer(
return [*fields, 'duplicate']
+ # Note: TransferOrder does not have "extra" line items
+ duplicate = DuplicateOptionsSerializer(
+ order.models.TransferOrder.objects.all(), copy_lines=True, copy_parameters=True
+ )
+
@staticmethod
def annotate_queryset(queryset):
"""Add extra information to the queryset.
diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py
index 10b944f40f..e72bd91de5 100644
--- a/src/backend/InvenTree/order/test_api.py
+++ b/src/backend/InvenTree/order/test_api.py
@@ -573,7 +573,7 @@ class PurchaseOrderTest(OrderTest):
# Duplicate with non-existent PK to provoke error
data['duplicate'] = {
- 'order_id': 10000001,
+ 'original': 10000001,
'copy_lines': True,
'copy_extra_lines': False,
}
@@ -584,7 +584,7 @@ class PurchaseOrderTest(OrderTest):
response = self.post(reverse('api-po-list'), data, expected_code=400)
data['duplicate'] = {
- 'order_id': 1,
+ 'original': 1,
'copy_lines': True,
'copy_extra_lines': False,
}
@@ -605,7 +605,7 @@ class PurchaseOrderTest(OrderTest):
data['reference'] = 'PO-9998'
data['duplicate'] = {
- 'order_id': 1,
+ 'original': 1,
'copy_lines': False,
'copy_extra_lines': True,
}
@@ -1792,7 +1792,7 @@ class SalesOrderTest(OrderTest):
{
'reference': 'SO-12345',
'customer': so.customer.pk,
- 'duplicate': {'order_id': so.pk, 'copy_parameters': False},
+ 'duplicate': {'original': so.pk, 'copy_parameters': False},
},
)
@@ -1809,7 +1809,7 @@ class SalesOrderTest(OrderTest):
{
'reference': 'SO-12346',
'customer': so.customer.pk,
- 'duplicate': {'order_id': so.pk},
+ 'duplicate': {'original': so.pk},
},
)
diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py
index a9a87c7e80..b2b7a361cb 100644
--- a/src/backend/InvenTree/part/serializers.py
+++ b/src/backend/InvenTree/part/serializers.py
@@ -407,67 +407,6 @@ class PartBriefSerializer(
)
-class DuplicatePartSerializer(serializers.Serializer):
- """Serializer for specifying options when duplicating a Part.
-
- The fields in this serializer control how the Part is duplicated.
- """
-
- class Meta:
- """Metaclass options."""
-
- fields = [
- 'part',
- 'copy_image',
- 'copy_bom',
- 'copy_parameters',
- 'copy_notes',
- 'copy_tests',
- ]
-
- part = serializers.PrimaryKeyRelatedField(
- queryset=Part.objects.all(),
- label=_('Original Part'),
- help_text=_('Select original part to duplicate'),
- required=True,
- )
-
- copy_image = serializers.BooleanField(
- label=_('Copy Image'),
- help_text=_('Copy image from original part'),
- required=False,
- default=False,
- )
-
- copy_bom = serializers.BooleanField(
- label=_('Copy BOM'),
- help_text=_('Copy bill of materials from original part'),
- required=False,
- default=False,
- )
-
- copy_parameters = serializers.BooleanField(
- label=_('Copy Parameters'),
- help_text=_('Copy parameter data from original part'),
- required=False,
- default=False,
- )
-
- copy_notes = serializers.BooleanField(
- label=_('Copy Notes'),
- help_text=_('Copy notes from original part'),
- required=False,
- default=True,
- )
-
- copy_tests = serializers.BooleanField(
- label=_('Copy Tests'),
- help_text=_('Copy test templates from original part'),
- required=False,
- default=False,
- )
-
-
class InitialStockSerializer(serializers.Serializer):
"""Serializer for creating initial stock quantity."""
@@ -601,7 +540,7 @@ class PartSerializer(
Used when displaying all details of a single component.
"""
- import_exclude_fields = ['creation_date', 'creation_user', 'duplicate']
+ import_exclude_fields = ['creation_date', 'creation_user']
class Meta:
"""Metaclass defining serializer fields."""
@@ -1007,11 +946,37 @@ class PartSerializer(
)
# Extra fields used only for creation of a new Part instance
- duplicate = DuplicatePartSerializer(
+ duplicate = InvenTree.serializers.DuplicateOptionsSerializer(
+ Part.objects.all(),
label=_('Duplicate Part'),
help_text=_('Copy initial data from another Part'),
- write_only=True,
- required=False,
+ copy_parameters=True,
+ copy_fields=[
+ {
+ 'name': 'copy_image',
+ 'label': _('Copy Image'),
+ 'help_text': _('Copy image from original part'),
+ 'default': False,
+ },
+ {
+ 'name': 'copy_bom',
+ 'label': _('Copy BOM'),
+ 'help_text': _('Copy bill of materials from original part'),
+ 'default': False,
+ },
+ {
+ 'name': 'copy_notes',
+ 'label': _('Copy Notes'),
+ 'help_text': _('Copy notes from original part'),
+ 'default': True,
+ },
+ {
+ 'name': 'copy_tests',
+ 'label': _('Copy Tests'),
+ 'help_text': _('Copy test templates from original part'),
+ 'default': False,
+ },
+ ],
)
initial_stock = InitialStockSerializer(
@@ -1077,7 +1042,7 @@ class PartSerializer(
# Copy data from original Part
if duplicate:
- original = duplicate['part']
+ original = duplicate['original']
if duplicate.get('copy_bom', False):
instance.copy_bom_from(original)
diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py
index 011c700884..7752e95dd4 100644
--- a/src/backend/InvenTree/part/test_api.py
+++ b/src/backend/InvenTree/part/test_api.py
@@ -1649,7 +1649,7 @@ class PartCreationTests(PartAPITestBase):
'testable': do_copy,
'assembly': do_copy,
'duplicate': {
- 'part': 100,
+ 'original': 100,
'copy_bom': do_copy,
'copy_notes': do_copy,
'copy_image': do_copy,
diff --git a/src/backend/InvenTree/web/tests.py b/src/backend/InvenTree/web/tests.py
index 04a25b73a1..41190526dc 100644
--- a/src/backend/InvenTree/web/tests.py
+++ b/src/backend/InvenTree/web/tests.py
@@ -30,7 +30,7 @@ class TemplateTagTest(InvenTreeTestCase):
shipped_js = resp.split('