Report printing refactor (#7074)

* Adds a new "generic" ReportTemplate model

* expose API endpoints

* Update model / migrations / serializer

* Add new mixin class to existing database models

* - Add detail view for report template
- Revert filters field behaviour

* Filter report list by provided item IDs

- Greatly simplify filtering logic compared to existing implemetation
- Expose to API schema

* Create data migration for converting *old* report templates

* Ignore internal reports for data migration

* Add report mixin to StockLocation model

* Provide model choices in admin interface

* Offload context data generation to the model classes

* Remove old report template models

* Refactor JS code in CUI

* Fix for API filtering

* Add data migration to delete old models

* Remove dead URL

* Updates

* Construct sample report templates on app start

* Bump API version

* Typo fix

* Fix incorrect context calls

* Add new LabelTemplate model

- ReportTemplate and LabelTemplate share common base
- Refactor previous migration

* Expose to admin interface

* Add in extra context from existing label models

* Add migration to create LabelTemplate instances from existing labels

* Add API endpoints for listing and updating LabelTemplate objects

* Adjust 'upload_to' path

* Refactor label printing

* Move default label templates

* Update API endpoints

* Update migrations

* Handle LookupError in migration

* Redirect the "label" API endpoint

* Add new model for handling result of template printing

* Refactor LabelPrinting mixin

* Unlink "labels" app entirely

* Fix typo

* Record 'plugin' used to generate a particular output

* Fix imports

* Generate label print response

- Still not good yet

* Refactoring label printing in CUI

* add "items" count to TemplateOutput model

* Fix for InvenTreeLabelSheetPlugin

* Remove old "label" app

* Make request object optional

* Fix filename generation

* Add help text for "model_type"

* Simplify TemplateTable

* Tweak TemplateTable

* Get template editor to display template data again

* Stringify template name

- Important, otherwise you get a TypeError instead of TemplateDoesNotExist

* Add hooks to reset plugin state

* fix context for StockLocation model

* Tweak log messages

* Fix incorrect serializer

* Cleanup TemplateTable

* Fix broken import

* Filter by target model type

* Remove manual file operations

* Update old migrations

- Remove references to functions that no longer exist

* Refactor asset / snippet uploading

* Update comments

* Retain original filename when editing templatese

* Cleanup

* Refactor model type filter to use new hook

* Add placeholder actions for printing labels and reports

* Improve hookiness

* Add new ReportOutput class

* Report printing works from PUI now!

* More inspired filename pattern for generated reports

* Fix template preview window

- Use new "output" response field across the board

* Remove outdated task

* Update data migration to use raw SQL

- If the 'labels' app is no longer available, this will fail
- So, use raw SQL instead

* Add more API endpoint defs

* Adds placeholder API endpoint for label printing

* Expose plugin field to the printing endpoint

* Adds plugin model type

* Hook to print labels

* Refactor action dropdown items

* Refactor report printing for CUI

* Refactor label print for CUI

- Still needs to handle custom printing options for plugin

* Fix migration

* Update ModelType dict

* playwright test fix

* Unit test fixes

* Fix model ruleset associations

* Fix for report.js

* Add support for "dynamic" fields in metadata.py

* Add in custom fields based on plugin

* Refactoring

* Reset plugin on form close

* Set custom timeout values

* Update migration

- Not atomic

* Cleanup

* Implement more printing actions

* Reduce timeout

* Unit test updates

* Fix part serializers

* Label printing works in CUI again

* js linting

* Update <ActionDropdown>

* Fix for label printing API endpoint

* Fix filterselectdrawer

* Improve button rendering

* Allow printing from StockLocationTable

* Add aria-labels to modal form fields

* Add test for printing stock item labels from table

* Add test for report printing

* Add unit testing for report template editing / preview

* Message refactor

* Refactor InvenTreeReportMixin class

* Update playwright test

* Update 'verbose_name' for a number of models

* Additional admin filtering

* Playwright test updates

* Run checks against new python lib branch

(temporary, will be reverted)

* remove old app reference

* fix testing ref

* fix app init

* remove old tests

* Revert custom target branch

* Expose label and report output objects to API

* refactor

* fix a few tests

* factor plugin_ref out

* fix options testing

* Update table field header

* re-enable full options testing

* fix missing plugin matching

* disable call assert

* Add custom related field for PluginConfig

- Uses 'key' rather than 'pk'
- Revert label print plugin to use slug

* Add support for custom pk field in metadata

* switch to labels for testing

* re-align report testing code

* disable version check

* fix url

* Implement lazy loading

* Allow blank plugin for printing

- Uses the builtin label printer if not specified

* Add printing actions for StockItem

* Fix for metadata helper

* Use key instead of pk in printing actions

* Support non-standard pk values in RelatedModelField

* pass context data to report serializers

* disable template / item discovery

* fix call

* Tweak unit test

* Run python checks against specific branch

* Add task for running docs server

- Option to compile schema as part of task

* Custom branch no longer needed

* Starting on documentation updates

* fix tests for reports

* fix label testing

* Update template context variables

* Refactor report context documentation

* Documentation cleanup

* Docs cleanup

* Include sample report files

* Fix links

* Link cleanup

* Integrate plugin example code into docs

* Code cleanup

* Fix type annotation

* Revert deleted variable

* remove templatetype

* remove unused imports

* extend context testing

* test if plg can print

* re-enable version check

* Update unit tests

* Fix test

* Adjust unit test

* Add debug statement to test

* Fix unit test

- Labels get printed against LabelTemplate items, duh

* Unit test update

* Unit test updates

* Test update

* Patch fix for <PartColumn> component

* Fix ReportSerialierBase class

- Re-initialize field options if not already set

* Fix unit test for sqlite

* Fix kwargs for non-blocking label printing

* Update playwright tests

* Tweak unit test

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver 2024-05-22 10:17:01 +10:00 committed by GitHub
parent d99b6ae81b
commit aa39582d89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
217 changed files with 4507 additions and 6762 deletions

1
.gitignore vendored
View File

@ -108,5 +108,6 @@ src/backend/InvenTree/web/static
InvenTree/web/static
# Generated docs files
docs/schema.yml
docs/docs/api/*.yml
docs/docs/api/schema/*.yml

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -15,4 +15,14 @@ POST {
}
```
For an example of a very simple action plugin, refer to `/src/backend/InvenTree/plugin/samples/integratoni/simpleactionplugin.py`
### Sample Plugin
A sample action plugin is provided in the `InvenTree` source code, which can be used as a template for creating custom action plugins:
::: plugin.samples.integration.simpleactionplugin.SimpleActionPlugin
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []

View File

@ -5,3 +5,15 @@ title: Schedule Mixin
## APICallMixin
The APICallMixin class provides basic functionality for integration with an external API.
### Sample Plugin
The following example demonstrates how to use the `APICallMixin` class to make a simple API call:
::: plugin.samples.integration.api_caller.SampleApiCallerPlugin
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []

View File

@ -2,7 +2,7 @@
title: Barcode Mixin
---
### Barcode Plugins
## Barcode Plugins
InvenTree supports decoding of arbitrary barcode data via a **Barcode Plugin** interface. Barcode data POSTed to the `/api/barcode/` endpoint will be supplied to all loaded barcode plugins, and the first plugin to successfully interpret the barcode data will return a response to the client.
@ -24,7 +24,21 @@ POST {
}
```
### Example
### Builtin Plugin
The InvenTree server includes a builtin barcode plugin which can decode QR codes generated by the server. This plugin is enabled by default.
::: plugin.builtin.barcodes.inventree_barcode.InvenTreeInternalBarcodePlugin
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
### Example Plugin
Please find below a very simple example that is executed each time a barcode is scanned.
```python

View File

@ -6,7 +6,24 @@ title: Currency Exchange Mixin
The `CurrencyExchangeMixin` class enabled plugins to provide custom backends for updating currency exchange rate information.
Any implementing classes must provide the `update_exchange_rates` method. A simple example is shown below (with fake data).
Any implementing classes must provide the `update_exchange_rates` method.
### Builtin Plugin
The default builtin plugin for handling currency exchange rates is the `InvenTreeCurrencyExchangePlugin` class.
::: plugin.builtin.integration.currency_exchange.InvenTreeCurrencyExchange
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
### Sample Plugin
A simple example is shown below (with fake data).
```python

View File

@ -15,56 +15,34 @@ When a certain (server-side) event occurs, the background worker passes the even
{% include 'img.html' %}
{% endwith %}
### Example (all events)
### Sample Plugin - All events
Implementing classes must at least provide a `process_event` function:
```python
class EventPlugin(EventMixin, InvenTreePlugin):
"""
A simple example plugin which responds to events on the InvenTree server.
::: plugin.samples.event.event_sample.EventPluginSample
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
This example simply prints out the event information.
A more complex plugin could respond to specific events however it wanted.
"""
NAME = "EventPlugin"
SLUG = "event"
TITLE = "Triggered Events"
def process_event(self, event, *args, **kwargs):
print(f"Processing triggered event: '{event}'")
```
### Example (specific events)
### Sample Plugin - Specific Events
If you want to process just some specific events, you can also implement the `wants_process_event` function to decide if you want to process this event or not. This function will be executed synchronously, so be aware that it should contain simple logic.
Overall this function can reduce the workload on the background workers significantly since less events are queued to be processed.
```python
class EventPlugin(EventMixin, InvenTreePlugin):
"""
A simple example plugin which responds to 'salesordershipment.completed' event on the InvenTree server.
::: plugin.samples.event.filtered_event_sample.FilteredEventPluginSample
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
This example simply prints out the event information.
A more complex plugin can run enhanced logic on this event.
"""
NAME = "EventPlugin"
SLUG = "event"
TITLE = "Triggered Events"
def wants_process_event(self, event):
"""Here you can decide if this event should be send to `process_event` or not."""
return event == "salesordershipment.completed"
def process_event(self, event, *args, **kwargs):
"""Here you can run you'r specific logic."""
print(f"Sales order was completely shipped: '{args}' '{kwargs}'")
```
### Events
## Events
Events are passed through using a string identifier, e.g. `build.completed`

View File

@ -172,6 +172,14 @@ InvenTree supplies the `InvenTreeLabelPlugin` out of the box, which generates a
The default plugin also features a *DEBUG* mode which generates a raw HTML output, rather than PDF. This can be handy for tracking down any template rendering errors in your labels.
::: plugin.builtin.labels.inventree_label.InvenTreeLabelPlugin
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
### Available Data
The *label* data are supplied to the plugin in both `PDF` and `PNG` formats. This provides compatibility with a great range of label printers "out of the box". Conversion to other formats, if required, is left as an exercise for the plugin developer.

View File

@ -29,3 +29,15 @@ If a locate plugin is installed and activated, the [InvenTree mobile app](../../
### Implementation
Refer to the [InvenTree source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/plugin/samples/locate/locate_sample.py) for a simple implementation example.
### Sample Plugin
A simple example is provided in the InvenTree code base:
::: plugin.samples.locate.locate_sample.SampleLocatePlugin
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []

View File

@ -52,6 +52,18 @@ Or to add a template file that will be rendered as javascript code, from the plu
Note : see convention for template directory above.
## Sample Plugin
A sample plugin is provided in the InvenTree code base:
::: plugin.samples.integration.custom_panel_sample.CustomPanelSample
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []
## Example Implementations
Refer to the `CustomPanelSample` example class in the `./plugin/samples/integration/` directory, for a fully worked example of how custom UI panels can be implemented.

View File

@ -14,48 +14,14 @@ A plugin which implements the ReportMixin mixin can define the `add_report_conte
Additionally the `add_label_context` method, allowing custom context data to be added to a label template at time of printing.
### Example
### Sample Plugin
A sample plugin which provides additional context data to the report templates can be found [in the InvenTree source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/plugin/samples/integration/report_plugin_sample.py):
A sample plugin which provides additional context data to the report templates is available:
```python
"""Sample plugin for extending reporting functionality"""
import random
from plugin import InvenTreePlugin
from plugin.mixins import ReportMixin
from report.models import PurchaseOrderReport
class SampleReportPlugin(ReportMixin, InvenTreePlugin):
"""Sample plugin which provides extra context data to a report"""
NAME = "Sample Report Plugin"
SLUG = "reportexample"
TITLE = "Sample Report Plugin"
DESCRIPTION = "A sample plugin which provides extra context data to a report"
VERSION = "1.0"
def some_custom_function(self):
"""Some custom function which is not required for the plugin to function"""
return random.randint(0, 100)
def add_report_context(self, report_instance, model_instance, request, context):
"""Add example content to the report instance"""
# We can add any extra context data we want to the report
# Generate a random string of data
context['random_text'] = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz', k=20))
# Call a custom method
context['random_int'] = self.some_custom_function()
# We can also add extra data to the context which is specific to the report type
context['is_purchase_order'] = isinstance(report_instance, PurchaseOrderReport)
# We can also use the 'request' object to add extra context data
context['request_method'] = request.method
```
::: plugin.samples.integration.report_plugin_sample.SampleReportPlugin
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []

View File

@ -18,45 +18,14 @@ The ScheduleMixin class provides a plugin with the ability to call functions at
{% include 'img.html' %}
{% endwith %}
### Example
### SamplePlugin
An example of a plugin which supports scheduled tasks:
```python
class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, InvenTreePlugin):
"""
Sample plugin which runs a scheduled task, and provides user configuration.
"""
NAME = "Scheduled Tasks"
SLUG = 'schedule'
SCHEDULED_TASKS = {
'global': {
'func': 'some_module.function',
'schedule': 'H', # Run every hour
},
'member': {
'func': 'foo',
'schedule': 'I', # Minutes
'minutes': 15,
},
}
SETTINGS = {
'SECRET': {
'name': 'A secret',
'description': 'User configurable value',
},
}
def foo(self):
"""
This function runs every 15 minutes
"""
secret_value = self.get_setting('SECRET')
print(f"foo - SECRET = {secret_value})
```
!!! info "More Info"
For more information on any of the methods described below, refer to the InvenTree source code. [A working example is available as a starting point](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/plugin/samples/integration/scheduled_task.py).
::: plugin.samples.integration.scheduled_task.ScheduledTaskPlugin
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []

View File

@ -15,7 +15,7 @@ The dict must be formatted similar to the following sample that shows how to use
Take a look at the settings defined in `InvenTree.common.models.InvenTreeSetting` for all possible parameters.
### Example
### Example Plugin
Below is a simple example of how a plugin can implement settings:

View File

@ -58,7 +58,7 @@ To indicate a *field* validation error (i.e. the validation error applies only t
Note that an error can be which corresponds to multiple model instance fields.
### Example
### Example Plugin
Presented below is a simple working example for a plugin which implements the `validate_model_instance` method:
@ -188,3 +188,15 @@ def increment_serial_number(self, serial: str):
return val
```
## Sample Plugin
A sample plugin which implements custom validation routines is provided in the InvenTree source code:
::: plugin.samples.integration.validation_sample.SampleValidatorPlugin
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []

View File

@ -14,7 +14,7 @@ Navigate to the "Settings" page and click on the "Display" tab, you should see t
{% include 'img.html' %}
{% endwith %}
The drop-down list let's you select any other color theme found in your static folder (see next section to find out how to [add color themes](#add-color-themes)). Once selected, click on the "Apply Theme" button for the new color theme to be activated.
The drop-down list let's you select any other color theme found in your static folder (see next section to find out how to [add color themes](#add-color-theme)). Once selected, click on the "Apply Theme" button for the new color theme to be activated.
!!! info "Per-user Setting"
Color themes are "user specific" which means that changing the color theme in your own settings won't affect other users.

View File

@ -98,7 +98,7 @@ While [line items](#line-items) must reference a particular stock item, extra li
## Return Order Reports
Custom [reports](../report/return_order.md) can be generated against each Return Order.
Custom [reports](../report/templates.md) can be generated against each Return Order.
### Calendar view

View File

@ -24,7 +24,7 @@ Refer to the [report documentation](../report/report.md) for further information
!!! warning "LaTeX Support"
LaTeX report templates are no longer supported for a number of technical and ideological reasons
[#1292](https://github.com/inventree/InvenTree/pull/1292) adds support for build order / work order reports. Refer to the [build report documentation](../report/build.md) for further information.
[#1292](https://github.com/inventree/InvenTree/pull/1292) adds support for build order / work order reports. Refer to the [report documentation](../report/templates.md) for further information.
### Inherited BOM Items

View File

@ -32,7 +32,7 @@ Details on how to create and manage manufacturer parts were added
[#1462](https://github.com/inventree/InvenTree/pull/1417) adds the ability to
create a QR code containing the URL of a StockItem, which can be opened directly
on a portable device using the camera or a QR code scanner. More details [here](../report/labels.md#url-style-qr-code).
on a portable device using the camera or a QR code scanner. More details [here](../report/labels.md).
## Major Bug Fixes

View File

@ -53,7 +53,7 @@ This release also provides a marked improvement in unit testing and code coverag
[#2372](https://github.com/inventree/InvenTree/pull/2372) provides an overhaul of notifications, allowing users to view their notifications directly in the InvenTree interface.
### Why are you hiding my name?
[#2861](https://github.com/inventree/InvenTree/pull/2861) adds several changes to enable admins to remove more of InvenTrees branding. Change logo, hide the about-modal for all but superusers and add custom messages to login and main navbar. Check out [the docs](../start/config.md#customisation-options).
[#2861](https://github.com/inventree/InvenTree/pull/2861) adds several changes to enable admins to remove more of InvenTrees branding. Change logo, hide the about-modal for all but superusers and add custom messages to login and main navbar. Check out [the docs](../start/config.md#customization-options)
### Label Printing Plugin

View File

@ -1,186 +0,0 @@
---
title: BOM Generation
---
## BOM Generation
The bill of materials is an essential part of the documentation that needs to be sent to the factory. A simple csv export is OK to be important into SMT machines. But for human readable documentation it might not be sufficient. Additional information is needed. The Inventree report system allows to generate BOM well formatted BOM reports.
### Context variables
| Variable | Description |
| --- | --- |
| bom_items | Query set that contains all BOM items |
| bom_items...sub_part | One component of the BOM |
| bom_items...quantity | Number of parts |
| bom_items...reference | Reference designators of the part |
| bom_items...substitutes | Query set that contains substitutes of the part if any exist in the BOM |
### Examples
#### BOM
The following picture shows a simple example for a PCB with just three components from two different parts.
{% with id="report-options", url="report/bom_example.png", description="BOM example" %} {% include 'img.html' %} {% endwith %}
This example has been created using the following html template:
```html
{% raw %}
{% extends "report/inventree_report_base.html" %}
{% load i18n %}
{% load report %}
{% load inventree_extras %}
{% block page_margin %}
margin-left: 2cm;
margin-right: 1cm;
margin-top: 4cm;
{% endblock %}
{% block bottom_left %}
content: "v{{report_revision}} - {% format_date date %}";
{% endblock %}
{% block bottom_center %}
content: "InvenTree v{% inventree_version %}";
{% endblock %}
{% block style %}
.header-left {
text-align: left;
float: left;
}
table {
border: 1px solid #eee;
border-radius: 3px;
border-collapse: collapse;
width: 100%;
font-size: 80%;
}
table td {
border: 1px solid #eee;
}
{% endblock %}
{% block header_content %}
<div class='header-left'>
<h3>{% trans "Bill of Materials" %}</h3>
</div>
{% endblock %}
{% block page_content %}
<table>
<tr> <td>Board</td><td>{{ part.IPN }}</td> </tr>
<tr> <td>Description</td><td>{{ part.description }}</td> </tr>
<tr> <td>User</td><td>{{ user }}</td> </tr>
<tr> <td>Date</td><td>{{ date }}</td> </tr>
<tr> <td>Number of different components (codes)</td><td>{{ bom_items.count }}</td> </tr>
</table>
<br>
<table class='table table-striped table-condensed'>
<thead>
<tr>
<th>{% trans "IPN" %}</th>
<th>{% trans "MPN" %}</th>
<th>{% trans "Manufacturer" %}</th>
<th>{% trans "Quantity" %}</th>
<th>{% trans "Reference" %}</th>
<th>{% trans "Substitute" %}</th>
</tr>
</thead>
<tbody>
{% for line in bom_items.all %}
<tr>
<td>{{ line.sub_part.IPN }}</td>
<td>{{ line.sub_part.name }}</td>
<td>
{% for manf in line.sub_part.manufacturer_parts.all %}
{{ manf.manufacturer.name }}
{% endfor %}
</td>
<td>{% decimal line.quantity %}</td>
<td>{{ line.reference }}</td>
<td>
{% for sub in line.substitutes.all %}
{{ sub.part.IPN }}<br>
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% endraw %}
```
#### Pick List
When all material has been allocated someone has to pick all things from the warehouse.
In case you need a printed pick list you can use the following template. This it just the
table. All other info and CSS has been left out for simplicity. Please have a look at the
BOM report for details.
{% raw %}
```html
<table class='changes-table'>
<thead>
<tr>
<th>Original IPN</th>
<th>Allocated Part</th>
<th>Location</th>
<th>PCS</th>
</tr>
</thead>
<tbody>
{% for line in build.allocated_stock.all %}
<tr>
<td> {{ line.bom_item.sub_part.IPN }} </td>
{% if line.stock_item.part.IPN != line.bom_item.sub_part.IPN %}
<td class='chg'> {{ line.stock_item.part.IPN }} </td>
{% else %}
<td> {{ line.stock_item.part.IPN }} </td>
{% endif %}
<td> {{ line.stock_item.location.pathstring }} </td>
<td> {{ line.quantity }} </td>
</tr>
{% endfor %}
</tbody>
</table>
```
{% endraw %}
Here we have a loop that runs through all allocated parts for the build. For each part
we list the original IPN from the BOM and the IPN of the allocated part. These can differ
in case you have substitutes or template/variants in the BOM. In case the parts differ
we use a different format for the table cell e.g. print bold font or red color.
For the picker we list the full path names of the stock locations and the quantity
that is needed for the build. This will result in the following printout:
{% with id="picklist", url="report/picklist.png", description="Picklist Example" %} {% include "img.html" %} {% endwith %}
For those of you who would like to replace the "/" by something else because it is hard
to read in some fonts use the following trick:
{% raw %}
```html
<td> {% for loc in line.stock_item.location.path %}{{ loc.name }}{% if not forloop.last %}-{% endif %}{% endfor %} </td>
```
{% endraw %}
Here we use location.path which is a query set that contains the location path up to the
topmost parent. We use a loop to cycle through that and print the .name of the entry followed
by a "-". The foorloop.last is a Django trick that allows us to not print the "-" after
the last entry. The result looks like here:
{% with id="picklist_with_path", url="report/picklist_with_path.png", description="Picklist Example" %} {% include "img.html" %} {% endwith %}
Finally added a `{% raw %}|floatformat:0{% endraw %}` to the quantity that removes the trailing zeros.
### Default Report Template
A default *BOM Report* template is provided out of the box, which is useful for generating simple test reports. Furthermore, it may be used as a starting point for developing custom BOM reports:
View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_bill_of_materials_report.html) for the default test report template.

View File

@ -1,324 +0,0 @@
---
title: Build Order Report
---
## Build Order Report
Custom build order reports may be generated against any given [Build Order](../build/build.md). For example, build order reports can be used to generate work orders.
### Build Filters
A build order report template may define a set of filters against which [Build Order](../build/build.md) items are sorted.
### Context Variables
In addition to the default report context variables, the following context variables are made available to the build order report template for rendering:
| Variable | Description |
| --- | --- |
| build | The build object the report is being generated against |
| part | The [Part](./context_variables.md#part) object that the build references |
| line_items | A shortcut for [build.line_items](#build) |
| bom_items | A shortcut for [build.bom_items](#build) |
| build_outputs | A shortcut for [build.build_outputs](#build) |
| reference | The build order reference string |
| quantity | Build order quantity (number of assemblies being built) |
#### build
The following variables are accessed by build.variable
| Variable | Description |
| --- | --- |
| active | Boolean that tells if the build is active |
| batch | Batch code transferred to build parts (optional) |
| line_items | A query set with all the build line items associated with the build |
| bom_items | A query set with all BOM items for the part being assembled |
| build_outputs | A queryset containing all build output ([Stock Item](../stock/stock.md)) objects associated with this build |
| can_complete | Boolean that tells if the build can be completed. Means: All material allocated and all parts have been build. |
| are_untracked_parts_allocated | Boolean that tells if all bom_items have allocated stock_items. |
| creation_date | Date where the build has been created |
| completion_date | Date the build was completed (or, if incomplete, the expected date of completion) |
| completed_by | The [User](./context_variables.md#user) that completed the build |
| is_overdue | Boolean that tells if the build is overdue |
| is_complete | Boolean that tells if the build is complete |
| issued_by | The [User](./context_variables.md#user) who created the build |
| link | External URL for extra information |
| notes | Text notes |
| parent | Reference to a parent build object if this is a sub build |
| part | The [Part](./context_variables.md#part) to be built (from component BOM items) |
| quantity | Build order quantity (total number of assembly outputs) |
| completed | The number out outputs which have been completed |
| reference | Build order reference (required, must be unique) |
| required_parts | A query set with all parts that are required for the build |
| responsible | Owner responsible for completing the build. This can be a user or a group. Depending on that further context variables differ |
| sales_order | References to a [Sales Order](./context_variables.md#salesorder) object for which this build is required (e.g. the output of this build will be used to fulfil a sales order) |
| status | The status of the build. 20 means 'Production' |
| sub_build_count | Number of sub builds |
| sub_builds | Query set with all sub builds |
| target_date | Date the build will be overdue |
| take_from | [StockLocation](./context_variables.md#stocklocation) to take stock from to make this build (if blank, can take from anywhere) |
| title | The full name of the build |
| description | The description of the build |
| allocated_stock.all | A query set with all allocated stock items for the build |
As usual items in a query sets can be selected by adding a .n to the set e.g. build.required_parts.0
will result in the first part of the list. Each query set has again its own context variables.
#### line_items
The `line_items` variable is a list of all build line items associated with the selected build. The following attributes are available for each individual line_item instance:
| Attribute | Description |
| --- | --- |
| .build | A reference back to the parent build order |
| .bom_item | A reference to the BOMItem which defines this line item |
| .quantity | The required quantity which is to be allocated against this line item |
| .part | A shortcut for .bom_item.sub_part |
| .allocations | A list of BuildItem objects which allocate stock items against this line item |
| .allocated_quantity | The total stock quantity which has been allocated against this line |
| .unallocated_quantity | The remaining quantity to allocate |
| .is_fully_allocated | Boolean value, returns True if the line item has sufficient stock allocated against it |
| .is_overallocated | Boolean value, returns True if the line item has more allocated stock than is required |
#### bom_items
| Attribute | Description |
| --- | --- |
| .reference | The reference designators of the components |
| .quantity | The number of components required to build |
| .overage | The extra amount required to assembly |
| .consumable | Boolean field, True if this is a "consumable" part which is not tracked through builds |
| .sub_part | The part at this position |
| .substitutes.all | A query set with all allowed substitutes for that part |
| .note | Extra text field which can contain additional information |
#### allocated_stock.all
| Attribute | Description |
| --- | --- |
| .bom_item | The bom item where this part belongs to |
| .stock_item | The allocated [StockItem](./context_variables.md#stockitem) |
| .quantity | The number of components needed for the build (components in BOM x parts to build) |
### Example
The following example will create a report with header and BOM. In the BOM table substitutes will be listed.
{% raw %}
```html
{% extends "report/inventree_report_base.html" %}
{% load i18n %}
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block page_margin %}
margin: 2cm;
margin-top: 4cm;
{% endblock %}
{% block style %}
.header-right {
text-align: right;
float: right;
}
.logo {
height: 20mm;
vertical-align: middle;
}
.details {
width: 100%;
border: 1px solid;
border-radius: 3px;
padding: 5px;
min-height: 42mm;
}
.details table {
overflow-wrap: break-word;
word-wrap: break-word;
width: 65%;
table-layout: fixed;
font-size: 75%;
}
.changes table {
overflow-wrap: break-word;
word-wrap: break-word;
width: 100%;
table-layout: fixed;
font-size: 75%;
border: 1px solid;
}
.changes-table th {
font-size: 100%;
border: 1px solid;
}
.changes-table td {
border: 1px solid;
}
.details table td:not(:last-child){
white-space: nowrap;
}
.details table td:last-child{
width: 50%;
padding-left: 1cm;
padding-right: 1cm;
}
.details-table td {
padding-left: 10px;
padding-top: 5px;
padding-bottom: 5px;
border-bottom: 1px solid #555;
}
{% endblock %}
{% block bottom_left %}
content: "v{{report_revision}} - {% format_date date %}";
{% endblock %}
{% block header_content %}
<!-- TODO - Make the company logo asset generic -->
<img class='logo' src="{% asset 'company_logo.png' %}" alt="logo" width="150">
<div class='header-right'>
<h3>
Build Order {{ build }}
</h3>
<br>
</div>
<hr>
{% endblock %}
{% block page_content %}
<div class='details'>
<table class='details-table'>
<tr>
<th>{% trans "Build Order" %}</th>
<td>{% internal_link build.get_absolute_url build %}</td>
</tr>
<tr>
<th>{% trans "Order" %}</th>
<td>{{ reference }}</td>
</tr>
<tr>
<th>{% trans "Part" %}</th>
<td>{% internal_link part.get_absolute_url part.IPN %}</td>
</tr>
<tr>
<th>{% trans "Quantity" %}</th>
<td>{{ build.quantity }}</td>
</tr>
<tr>
<th>{% trans "Description" %}</th>
<td>{{ build.title }}</td>
</tr>
<tr>
<th>{% trans "Issued" %}</th>
<td>{% format_date build.creation_date %}</td>
</tr>
<tr>
<th>{% trans "Target Date" %}</th>
<td>
{% if build.target_date %}
{% format_date build.target_date %}
{% else %}
<em>Not specified</em>
{% endif %}
</td>
</tr>
{% if build.parent %}
<tr>
<th>{% trans "Required For" %}</th>
<td>{% internal_link build.parent.get_absolute_url build.parent %}</td>
</tr>
{% endif %}
{% if build.issued_by %}
<tr>
<th>{% trans "Issued By" %}</th>
<td>{{ build.issued_by }}</td>
</tr>
{% endif %}
{% if build.responsible %}
<tr>
<th>{% trans "Responsible" %}</th>
<td>{{ build.responsible }}</td>
</tr>
{% endif %}
<tr>
<th>{% trans "Sub builds count" %}</th>
<td>{{ build.sub_build_count }}</td>
</tr>
{% if build.sub_build_count > 0 %}
<tr>
<th>{% trans "Sub Builds" %}</th>
<td>{{ build.sub_builds }}</td>
</tr>
{% endif %}
<tr>
<th>{% trans "Overdue" %}</th>
<td>{{ build.is_overdue }}</td>
</tr>
<tr>
<th>{% trans "Can complete" %}</th>
<td>{{ build.can_complete }}</td>
</tr>
</table>
</div>
<h3>{% trans "Notes" %}</h3>
{% if build.notes %}
{{ build.notes|markdownify }}
{% endif %}
<h3>{% trans "Parts" %}</h3>
<div class='changes'>
<table class='changes-table'>
<thead>
<tr>
<th>Original IPN</th>
<th>Reference</th>
<th>Replace width IPN</th>
</tr>
</thead>
<tbody>
{% for line in build.bom_items %}
<tr>
<td> {{ line.sub_part.IPN }} </td>
<td> {{ line.reference }} </td>
<td> {{ line.substitutes.all.0.part.IPN }} </td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
```
{% endraw %}
This will result a report page like this:
{% with id="report-options", url="build/report-61.png", description="Report Example Builds" %} {% include "img.html" %} {% endwith %}
### Default Report Template
A default *Build Report* template is provided out of the box, which is useful for generating simple test reports. Furthermore, it may be used as a starting point for developing custom BOM reports:
View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_build_order_base.html) for the default build report template.

View File

@ -2,64 +2,245 @@
title: Context Variables
---
## Context Variables
### Report
Context variables are provided to each template when it is rendered. The available context variables depend on the model type for which the template is being rendered.
!!! info "Specific Report Context"
Specific report types may have additional context variables, see below.
### Global Context
Each report has access to a number of context variables by default. The following context variables are provided to every report template:
In addition to the model-specific context variables, the following global context variables are available to all templates:
| Variable | Description |
| --- | --- |
| base_url | The base URL for the InvenTree instance |
| date | Current date, represented as a Python datetime.date object |
| datetime | Current datetime, represented as a Python datetime object |
| page_size | The specified page size for this report, e.g. `A4` or `Letter landscape` |
| report_template | The report template model instance |
| report_name | Name of the report template |
| report_description | Description of the report template |
| report_revision | Revision of the report template |
| request | Django request object |
| request | The Django request object associated with the printing process |
| template | The report template instance which is being rendered against |
| template_description | Description of the report template |
| template_name | Name of the report template |
| template_revision | Revision of the report template |
| user | User who made the request to render the template |
#### Label
::: report.models.ReportTemplateBase.base_context
options:
show_source: True
Certain types of labels have different context variables then other labels.
### Report Context
##### Stock Item Label
The following variables are made available to the StockItem label template:
In addition to the [global context](#global-context), all *report* templates have access to the following context variables:
| Variable | Description |
| -------- | ----------- |
| item | The [StockItem](./context_variables.md#stockitem) object itself |
| part | The [Part](./context_variables.md#part) object which is referenced by the [StockItem](./context_variables.md#stockitem) object |
| name | The `name` field of the associated Part object |
| ipn | The `IPN` field of the associated Part object |
| revision | The `revision` field of the associated Part object |
| quantity | The `quantity` field of the StockItem object |
| serial | The `serial` field of the StockItem object |
| uid | The `uid` field of the StockItem object |
| tests | Dict object of TestResult data associated with the StockItem |
| --- | --- |
| page_size | The page size of the report |
| landscape | Boolean value, True if the report is in landscape mode |
Note that custom plugins may also add additional context variables to the report context.
::: report.models.ReportTemplate.get_context
options:
show_source: True
### Label Context
In addition to the [global context](#global-context), all *label* templates have access to the following context variables:
| Variable | Description |
| --- | --- |
| width | The width of the label (in mm) |
| height | The height of the label (in mm) |
Note that custom plugins may also add additional context variables to the label context.
::: report.models.LabelTemplate.get_context
options:
show_source: True
## Template Types
Templates (whether for generating [reports](./report.md) or [labels](./labels.md)) are rendered against a particular "model" type. The following model types are supported, and can have templates renderer against them:
| Model Type | Description |
| --- | --- |
| [build](#build-order) | A [Build Order](../build/build.md) instance |
| [buildline](#build-line) | A [Build Order Line Item](../build/build.md) instance |
| [salesorder](#sales-order) | A [Sales Order](../order/sales_order.md) instance |
| [returnorder](#return-order) | A [Return Order](../order/return_order.md) instance |
| [purchaseorder](#purchase-order) | A [Purchase Order](../order/purchase_order.md) instance |
| [stockitem](#stock-item) | A [StockItem](../stock/stock.md#stock-item) instance |
| [stocklocation](#stock-location) | A [StockLocation](../stock/stock.md#stock-location) instance |
| [part](#part) | A [Part](../part/part.md) instance |
### Build Order
When printing a report or label against a [Build Order](../build/build.md) object, the following context variables are available:
| Variable | Description |
| --- | --- |
| bom_items | Query set of all BuildItem objects associated with the BuildOrder |
| build | The BuildOrder instance itself |
| build_outputs | Query set of all BuildItem objects associated with the BuildOrder |
| line_items | Query set of all build line items associated with the BuildOrder |
| part | The Part object which is being assembled in the build order |
| quantity | The total quantity of the part being assembled |
| reference | The reference field of the BuildOrder |
| title | The title field of the BuildOrder |
::: build.models.Build.report_context
options:
show_source: True
### Build Line
When printing a report or label against a [BuildOrderLineItem](../build/build.md) object, the following context variables are available:
| Variable | Description |
| --- | --- |
| allocated_quantity | The quantity of the part which has been allocated to this build |
| allocations | A query set of all StockItem objects which have been allocated to this build line |
| bom_item | The BomItem associated with this line item |
| build | The BuildOrder instance associated with this line item |
| build_line | The build line instance itself |
| part | The sub-part (component) associated with the linked BomItem instance |
| quantity | The quantity required for this line item |
::: build.models.BuildLine.report_context
options:
show_source: True
### Sales Order
When printing a report or label against a [SalesOrder](../order/sales_order.md) object, the following context variables are available:
| Variable | Description |
| --- | --- |
| customer | The customer object associated with the SalesOrder |
| description | The description field of the SalesOrder |
| extra_lines | Query set of all extra lines associated with the SalesOrder |
| lines | Query set of all line items associated with the SalesOrder |
| order | The SalesOrder instance itself |
| reference | The reference field of the SalesOrder |
| title | The title (string representation) of the SalesOrder |
::: order.models.Order.report_context
options:
show_source: True
### Return Order
When printing a report or label against a [ReturnOrder](../order/return_order.md) object, the following context variables are available:
| Variable | Description |
| --- | --- |
| customer | The customer object associated with the ReturnOrder |
| description | The description field of the ReturnOrder |
| extra_lines | Query set of all extra lines associated with the ReturnOrder |
| lines | Query set of all line items associated with the ReturnOrder |
| order | The ReturnOrder instance itself |
| reference | The reference field of the ReturnOrder |
| title | The title (string representation) of the ReturnOrder |
### Purchase Order
When printing a report or label against a [PurchaseOrder](../order/purchase_order.md) object, the following context variables are available:
| Variable | Description |
| --- | --- |
| description | The description field of the PurchaseOrder |
| extra_lines | Query set of all extra lines associated with the PurchaseOrder |
| lines | Query set of all line items associated with the PurchaseOrder |
| order | The PurchaseOrder instance itself |
| reference | The reference field of the PurchaseOrder |
| supplier | The supplier object associated with the PurchaseOrder |
| title | The title (string representation) of the PurchaseOrder |
### Stock Item
When printing a report or label against a [StockItem](../stock/stock.md#stock-item) object, the following context variables are available:
| Variable | Description |
| --- | --- |
| barcode_data | Generated barcode data for the StockItem |
| barcode_hash | Hash of the barcode data |
| batch | The batch code for the StockItem |
| child_items | Query set of all StockItem objects which are children of this StockItem |
| ipn | The IPN (internal part number) of the associated Part |
| installed_items | Query set of all StockItem objects which are installed in this StockItem |
| item | The StockItem object itself |
| name | The name of the associated Part |
| part | The Part object which is associated with the StockItem |
| qr_data | Generated QR code data for the StockItem |
| qr_url | Generated URL for embedding in a QR code |
| parameters | Dict object containing the parameters associated with the base Part |
| quantity | The quantity of the StockItem |
| result_list | FLattened list of TestResult data associated with the stock item |
| results | Dict object of TestResult data associated with the StockItem |
| serial | The serial number of the StockItem |
| stock_item | The StockItem object itself (shadow of 'item') |
| tests | Dict object of TestResult data associated with the StockItem (shadow of 'results') |
| test_keys | List of test keys associated with the StockItem |
| test_template_list | List of test templates associated with the StockItem |
| test_templates | Dict object of test templates associated with the StockItem |
::: stock.models.StockItem.report_context
options:
show_source: True
##### Stock Location Label
### Stock Location
The following variables are made available to the StockLocation label template:
When printing a report or label against a [StockLocation](../stock/stock.md#stock-location) object, the following context variables are available:
| Variable | Description |
| -------- | ----------- |
| location | The [StockLocation](./context_variables.md#stocklocation) object itself |
| --- | --- |
| location | The StockLocation object itself |
| qr_data | Formatted QR code data for the StockLocation |
| parent | The parent StockLocation object |
| stock_location | The StockLocation object itself (shadow of 'location') |
| stock_items | Query set of all StockItem objects which are located in the StockLocation |
::: stock.models.StockLocation.report_context
options:
show_source: True
### Part
When printing a report or label against a [Part](../part/part.md) object, the following context variables are available:
| Variable | Description |
| --- | --- |
| bom_items | Query set of all BomItem objects associated with the Part |
| category | The PartCategory object associated with the Part |
| description | The description field of the Part |
| IPN | The IPN (internal part number) of the Part |
| name | The name of the Part |
| parameters | Dict object containing the parameters associated with the Part |
| part | The Part object itself |
| qr_data | Formatted QR code data for the Part |
| qr_url | Generated URL for embedding in a QR code |
| revision | The revision of the Part |
| test_template_list | List of test templates associated with the Part |
| test_templates | Dict object of test templates associated with the Part |
::: part.models.Part.report_context
options:
show_source: True
## 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.
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.
### Parts
!!! incomplete "TODO"
This section requires further work
#### Part
Each part object has access to a lot of context variables about the part. The following context variables are provided when accessing a `Part` object:
Each part object has access to a lot of context variables about the part. The following context variables are provided when accessing a `Part` object from within the template.
| Variable | Description |
|----------|-------------|
@ -106,6 +287,7 @@ Each part object has access to a lot of context variables about the part. The fo
#### Part Category
| Variable | Description |
|----------|-------------|
| name | Name of this category |
@ -117,6 +299,7 @@ Each part object has access to a lot of context variables about the part. The fo
#### StockItem
| Variable | Description |
|----------|-------------|
| parent | Link to another [StockItem](./context_variables.md#stockitem) from which this StockItem was created |
@ -139,7 +322,7 @@ Each part object has access to a lot of context variables about the part. The fo
| notes | Extra notes field |
| 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](./context_variables.md#purchaseorder) (if this stock item was created from a PurchaseOrder) |
| purchase_order | Link to a [PurchaseOrder](./context_variables.md#purchase-order) (if this stock item was created from a PurchaseOrder) |
| infinite | If True this [StockItem](./context_variables.md#stockitem) can never be exhausted |
| sales_order | Link to a [SalesOrder](./context_variables.md#salesorder) object (if the StockItem has been assigned to a SalesOrder) |
| purchase_price | The unit purchase price for this [StockItem](./context_variables.md#stockitem) - this is the unit price at time of purchase (if this item was purchased from an external supplier) |
@ -164,6 +347,7 @@ Each part object has access to a lot of context variables about the part. The fo
#### Company
| Variable | Description |
|----------|-------------|
| name | Name of the company |
@ -184,6 +368,7 @@ Each part object has access to a lot of context variables about the part. The fo
#### Address
| Variable | Description |
|----------|-------------|
| line1 | First line of the postal address |
@ -194,9 +379,6 @@ Each part object has access to a lot of context variables about the part. The fo
#### Contact
Contacts are added to companies. Actually the company has no link to the contacts.
You can search the company object of the contact.
| Variable | Description |
|----------|-------------|
| company | Company object where the contact belongs to |
@ -207,6 +389,7 @@ You can search the company object of the contact.
#### SupplierPart
| Variable | Description |
|----------|-------------|
| part | Link to the master Part (Obsolete) |
@ -226,24 +409,13 @@ You can search the company object of the contact.
| has_price_breaks | Whether this [SupplierPart](./context_variables.md#supplierpart) has price breaks |
| manufacturer_string | Format a MPN string for this [SupplierPart](./context_variables.md#supplierpart). Concatenates manufacture name and part number. |
### Manufacturers
!!! incomplete "TODO"
This section requires further work
#### Manufacturer
| Variable | Description |
|----------|-------------|
#### ManufacturerPart
| Variable | Description |
|----------|-------------|
### Orders
The [Purchase Order](../order/purchase_order.md) context variables are described in the [Purchase Order](./purchase_order.md) section.
#### Purchase Order
!!! note "TODO"
This section is incomplete
#### SalesOrder

View File

@ -18,7 +18,7 @@ Some common functions are provided for use in custom report and label templates.
When making use of helper functions within a template, it can be useful to store the result of the function to a variable, rather than immediately rendering the output.
For example, using the [render_currency](#rendering-currency) helper function, we can store the output to a variable which can be used at a later point in the template:
For example, using the [render_currency](#currency-formatting) helper function, we can store the output to a variable which can be used at a later point in the template:
```html
{% raw %}
@ -272,7 +272,7 @@ A template tag is provided to load the InvenTree logo image into a report. You c
### Custom Logo
If the system administrator has enabled a [custom logo](../start/config.md#customisation-options), then this logo will be used instead of the base InvenTree logo.
If the system administrator has enabled a [custom logo](../start/config.md#customization-options) then this logo will be used instead of the base InvenTree logo.
This is a useful way to get a custom company logo into your reports.
@ -287,7 +287,7 @@ If you have a custom logo, but explicitly wish to load the InvenTree logo itself
## Report Assets
[Report Assets](./report.md#report-assets) are files specifically uploaded by the user for inclusion in generated reports and labels.
[Report Assets](./templates.md#report-assets) 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:

View File

@ -11,17 +11,6 @@ Custom labels can be generated using simple HTML templates, with support for QR-
Simple (generic) label templates are supplied 'out of the box' with InvenTree - however support is provided for generation of extremely specific custom labels, to meet any particular requirement.
## Label Types
The following types of labels are available
| Label Type | Description |
| --- | --- |
| [Part Labels](./labels/part_labels.md) | Print labels for individual parts |
| [Stock Labels](./labels/stock_labels.md) | Print labels for individual stock items |
| [Location Labels](./labels/location_labels.md) | Print labels for individual stock locations
| [Build Labels](./labels/build_labels.md) | Print labels for individual build order line items |
## Label Templates
Label templates are written using a mixture of [HTML](https://www.w3schools.com/html/) and [CSS](https://www.w3schools.com/css). [Weasyprint](https://weasyprint.org/) templates support a *subset* of HTML and CSS features. In addition to supporting HTML and CSS formatting, the label templates support the Django templating engine, allowing conditional formatting of the label data.

View File

@ -1,118 +0,0 @@
---
title: Build Labels
---
## Build Line Labels
Build label templates are used to generate labels for individual build order line items.
### Creating Build Line Label Templates
Build label templates are added (and edited) via the [admin interface](../../settings/admin.md).
### Printing Build Line Labels
Build line labels are printed from the Build Order page, under the *Allocate Stock* tab. Multiple line items can be selected for printing:
{% with id='print_build_labels', url='report/label_build_print.png', description='Print build line labels' %}
{% include 'img.html' %}
{% endwith %}
### Context Data
The following context variables are made available to the Build Line label template:
| Variable | Description |
| --- | --- |
| build_line | The build_line instance |
| build | The build order to which the build_line is linked |
| bom_item | The bom_item to which the build_line is linked |
| part | The required part for this build_line instance. References bom_item.sub_part |
| quantity | The total quantity required for the build line |
| allocated_quantity | The total quantity which has been allocated against the build line |
| allocations | A queryset containing the allocations made against the build_line |
## Example
A simple example template is shown below:
```html
{% raw %}
{% extends "label/label_base.html" %}
{% load barcode report %}
{% load inventree_extras %}
{% block style %}
{{ block.super }}
.label {
margin: 1mm;
}
.qr {
height: 28mm;
width: 28mm;
position: relative;
top: 0mm;
right: 0mm;
float: right;
}
.label-table {
width: 100%;
border-collapse: collapse;
border: 1pt solid black;
}
.label-table tr {
width: 100%;
border-bottom: 1pt solid black;
padding: 2.5mm;
}
.label-table td {
padding: 3mm;
}
{% endblock style %}
{% block content %}
<div class='label'>
<table class='label-table'>
<tr>
<td>
<b>Build Order:</b> {{ build.reference }}<br>
<b>Build Qty:</b> {% decimal build.quantity %}<br>
</td>
<td>
<img class='qr' alt='build qr' src='{% qrcode build.barcode %}'>
</td>
</tr>
<tr>
<td>
<b>Part:</b> {{ part.name }}<br>
{% if part.IPN %}
<b>IPN:</b> {{ part.IPN }}<br>
{% endif %}
<b>Qty / Unit:</b> {% decimal bom_item.quantity %} {% if part.units %}[{{ part.units }}]{% endif %}<br>
<b>Qty Total:</b> {% decimal quantity %} {% if part.units %}[{{ part.units }}]{% endif %}
</td>
<td>
<img class='qr' alt='part qr' src='{% qrcode part.barcode %}'>
</td>
</tr>
</table>
</div>
{% endblock content %}
{% endraw %}
```
Which results in a label like:
{% with id='build_label_example', url='report/label_build_example.png', description='Example build line labels' %}
{% include 'img.html' %}
{% endwith %}

View File

@ -1,24 +0,0 @@
---
title: Location Labels
---
## Stock Location Labels
Stock Location label templates are used to generate labels for individual Stock Locations.
### Creating Stock Location Label Templates
Stock Location label templates are added (and edited) via the admin interface.
### Printing Stock Location Labels
To print a single label from the Stock Location detail view, select the *Print Label* option.
### Context Data
The following variables are made available to the StockLocation label template:
| Variable | Description |
| -------- | ----------- |
| location | The [StockLocation](../context_variables.md#stocklocation) object itself |

View File

@ -1,91 +0,0 @@
---
title: Part Labels
---
## Part Labels
Part label templates are used to generate labels for individual Part instances.
### Creating Part Label Templates
Part label templates are added (and edited) via the admin interface.
### Printing Part Labels
Part label can be printed using the following approaches:
To print a single part label from the Part detail view, select the *Print Label* option.
To print multiple part labels, select multiple parts in the part table and select the *Print Labels* option.
### Context Data
The following context variables are made available to the Part label template:
| Variable | Description |
| -------- | ----------- |
| part | The [Part](../context_variables.md#part) object |
| category | The [Part Category](../context_variables.md#part-category) which contains the Part |
| name | The name of the part |
| description | The description text for the part |
| IPN | Internal part number (IPN) for the part |
| revision | Part revision code |
| qr_data | String data which can be rendered to a QR code |
| parameters | Map (Python dictionary) object containing the parameters associated with the part instance |
#### Parameter Values
The part parameter *values* can be accessed by parameter name lookup in the template, as follows:
```html
{% raw %}
Part: {{ part.name }}
Length: {{ parameters.length }}
{% endraw %}
```
!!! warning "Spaces"
Note that for parameters which include a `space` character in their name, lookup using the "dot" notation won't work! In this case, try using the [key lookup](../helpers.md#key-access) method:
```html
{% raw %}
Voltage Rating: {% getkey parameters "Voltage Rating" %}
{% endraw %}
```
#### Parameter Data
If you require access to the parameter data itself, and not just the "value" of a particular parameter, you can use the `part_parameter` [helper function](../helpers.md#part-parameters).
For example, the following label template can be used to generate a label which contains parameter data in addition to parameter units:
```html
{% raw %}
{% extends "label/label_base.html" %}
{% load report %}
{% block content %}
{% part_parameter part "Width" as width %}
{% part_parameter part "Length" as length %}
<div>
Part: {{ part.full_name }}<br>
Width: {{ width.data }} [{{ width.units }}]<br>
Length: {{ length.data }} [{{ length.units }}]
</div>
{% endblock content %}
{% endraw %}
```
The following label is produced:
{% with id="report-parameters", url="report/label_with_parameters.png", description="Label with parameters" %}
{% include 'img.html' %}
{% endwith %}

View File

@ -1,61 +0,0 @@
---
title: Stock Labels
---
## Stock Item Labels
Stock Item label templates are used to generate labels for individual Stock Items.
### Creating Stock Item Label Templates
Stock Item label templates are added (and edited) via the admin interface.
### Printing Stock Item Labels
Stock Item labels can be printed using the following approaches:
To print a single stock item from the Stock Item detail view, select the *Print Label* option as shown below:
{% with id='item_label_single', url='report/label_stock_print_single.png', description='Print single stock item label' %}
{% include 'img.html' %}
{% endwith %}
To print multiple stock items from the Stock table view, select the *Print Labels* option as shown below:
{% with id='item_label_multiple', url='report/label_stock_print_multiple.png', description='Print multiple stock item labels' %}
{% include 'img.html' %}
{% endwith %}
### Context Data
The following variables are made available to the StockItem label template:
| Variable | Description |
| -------- | ----------- |
| item | The [StockItem](../context_variables.md#stockitem) object itself |
| part | The [Part](../context_variables.md#part) object which is referenced by the [StockItem](../context_variables.md#stockitem) object |
| name | The `name` field of the associated Part object |
| ipn | The `IPN` field of the associated Part object |
| revision | The `revision` field of the associated Part object |
| quantity | The `quantity` field of the StockItem object |
| serial | The `serial` field of the StockItem object |
| uid | The `uid` field of the StockItem object |
| tests | Dict object of TestResult data associated with the StockItem |
| parameters | Dict object containing the parameters associated with the base Part |
### URL-style QR code
Stock Item labels support [QR code](../barcodes.md#qr-code) containing the stock item URL, which can be
scanned and opened directly
on a portable device using the camera or a QR code scanner. To generate a URL-style QR code for stock item in the [label HTML template](../labels.md#label-templates), add the
following HTML tag:
``` html
{% raw %}
<img class='custom_qr_class' src='{% qrcode qr_url %}'>
{% endraw %}
```
Make sure to customize the `custom_qr_class` CSS class to define the position of the QR code
on the label.

View File

@ -1,65 +0,0 @@
---
title: Purchase Order Report
---
## Purchase Order Reports
Custom purchase order reports may be generated against any given [Purchase Order](../order/purchase_order.md). For example, purchase order reports could be used to generate a pdf of the order to send to a supplier.
### Purchase Order Filters
The report template can be filtered against available [Purchase Order](../order/purchase_order.md) instances.
### Context Variables
In addition to the default report context variables, the following variables are made available to the purchase order report template for rendering:
| Variable | Description |
| --- | --- |
| order | The specific Purchase Order object |
| reference | The order reference field (can also be accessed as `{% raw %}{{ order.reference }}{% endraw %}`) |
| description | The order description field |
| supplier | The [supplier](../order/company.md#suppliers) associated with this purchase order |
| lines | A list of available line items for this order |
| extra_lines | A list of available *extra* line items for this order |
| order.created_by | The user who created the order |
| order.responsible | The user or group who is responsible for the order |
| order.creation_date | The date when the order was created |
| order.target_date | The date when the order should arrive |
| order.if_overdue | Boolean value that tells if the target date has passed |
| order.currency | The currency code associated with this order, e.g. 'AUD' |
| order.contact | The [contact](./context_variables.md#contact) object associated with this order |
#### Lines
Each line item (available within the `lines` list) has sub variables, as follows:
| Variable | Description |
| --- | --- |
| quantity | The quantity of the part to be ordered |
| part | The [supplierpart ](./context_variables.md#supplierpart) object to be ordered |
| reference | The reference given in the part of the order |
| notes | The notes given in the part of the order |
| target_date | The date when the part should arrive. Each part can have an individual date |
| price | The unit price the line item |
| total_line_price | The total price for this line item, calculated from the unit price and quantity |
| destination | The stock location where the part will be stored |
A simple example below shows how to use the context variables for each line item:
```html
{% raw %}
{% for line in lines %}
Internal Part: {{ line.part.part.name }} - <i>{{ line.part.part.description }}</i>
SKU: {{ line.part.SKU }}
Price: {% render_currency line.total_line_price %}
{% endfor %}
{% endraw %}
```
### Default Report Template
A default *Purchase Order Report* template is provided out of the box, which is useful for generating simple test reports. Furthermore, it may be used as a starting point for developing custom BOM reports:
View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_po_report_base.html) for the default purchase order report template.

View File

@ -1,14 +1,14 @@
---
title: Report Generation
title: Report and LabelGeneration
---
## Custom Reporting
## Custom Reports
InvenTree supports a customizable reporting ecosystem, allowing the user to develop reporting templates that meet their particular needs.
InvenTree supports a customizable reporting ecosystem, allowing the user to develop document templates that meet their particular needs.
PDF reports are generated from custom HTML template files which are written by the user.
PDF files are generated from custom HTML template files which are written by the user.
Reports are used in a variety of situations to format data in a friendly format for printing, distribution, conformance and testing.
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.
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.
@ -44,290 +44,3 @@ For example, rendering the name of a part (which is available in the particular
</p></i>
{% endraw %}
```
### Context Variables
!!! info "Context Variables"
Templates will have different variables available to them depending on the report type. Read the detailed information on each available report type for further information.
Please refer to the [Context variables](./context_variables.md) page.
### Conditional Rendering
The django template system allows for conditional rendering, providing conditional flow statements such as:
```
{% raw %}
{% if <condition> %}
{% do_something %}
{% elif <other_condition> %}
<!-- something else -->
{% else %}
<!-- finally -->
{% endif %}
{% endraw %}
```
```
{% raw %}
{% for <item> in <list> %}
Item: {{ item }}
{% endfor %}
{% endraw %}
```
!!! info "Conditionals"
Refer to the [django template language documentation]({% include "django.html" %}/ref/templates/language/) for more information.
### Localization Issues
Depending on your localization scheme, inputting raw numbers into the formatting section template can cause some unintended issues. Consider the block below which specifies the page size for a rendered template:
```html
{% raw %}
<head>
<style>
@page {
size: {{ width }}mm {{ height }}mm;
margin: 0mm;
}
</style>
</head>
{% endraw %}
```
If localization settings on the InvenTree server use a comma (`,`) character as a decimal separator, this may produce an output like:
```html
{% raw %}
{% endraw %}
<head>
<style>
@page {
size: 57,3mm 99,0mm;
margin: 0mm;
}
</style>
</head>
```
The resulting `{% raw %}<style>{% endraw %}` CSS block will be *invalid*!
So, if you are writing a template which has custom formatting, (or any other sections which cannot handle comma decimal separators) you must wrap that section in a `{% raw %}{% localize off %}{% endraw %}` block:
```html
{% raw %}
{% load l10n %}
<head>
<style>
@page {
{% localize off %}
size: {{ width }}mm {{ height }}mm;
{% endlocalize %}
margin: 0mm;
}
</style>
</head>
{% endraw %}
```
!!! tip "Close it out"
Don't forget to end with a `{% raw %}{% endlocalize %}{% endraw %}` tag!
!!! tip "l10n"
You will need to add `{% raw %}{% load l10n %}{% endraw %}` to the top of your template file to use the `{% raw %}{% localize %}{% endraw %}` tag.
### Extending with Plugins
The [ReportMixin plugin class](../extend/plugins/report.md) allows reporting functionality to be extended with custom features.
## Report Types
InvenTree supports the following reporting functionality:
| Report Type | Description |
| --- | --- |
| [Test Report](./test.md) | Format results of a test report against for a particular StockItem |
| [Build Order Report](./build.md) | Format a build order report |
| [Purchase Order Report](./purchase_order.md) | Format a purchase order report |
| [Sales Order Report](./sales_order.md) | Format a sales order report |
| [Return Order Report](./return_order.md) | Format a return order report |
| [Stock Location Report](./stock_location.md) | Format a stock location report |
### Default Reports
InvenTree is supplied with a number of default templates "out of the box". These are generally quite simple, but serve as a starting point for building custom reports to suit a specific need.
!!! tip "Read the Source"
The source code for the default reports is [available on GitHub](https://github.com/inventree/InvenTree/tree/master/src/backend/InvenTree/report/templates/report). Use this as a guide for generating your own reports!
## Creating Reports
Report templates are created (and edited) via the [admin interface](../settings/admin.md), under the *Report* section. Select the certain type of report template you are wanting to create, and press the *Add* button in the top right corner:
{% with id="report-create", url="report/add_report_template.png", description="Create new report" %}
{% include 'img.html' %}
{% endwith %}
!!! tip "Staff Access Only"
Only users with staff access can upload or edit report template files.
!!! 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.
### Name and Description
Each report template requires a name and description, which identify and describe the report template.
### 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.
### Filename Pattern
The filename pattern used to generate the output `.pdf` file. Defaults to "report.pdf".
The filename pattern allows custom rendering with any context variables which are available to the report. For example, a [test report](./test.md) for a particular [Stock Item](../stock/stock.md#stock-item) can use the part name and serial number of the stock item when generating the report name:
{% with id="report-filename-pattern", url="report/filename_pattern.png", description="Report filename pattern" %}
{% include 'img.html' %}
{% endwith %}
### Report Filters
Each type of report provides a *filters* field, which can be used to filter which items a report can be generated against. The target of the *filters* field depends on the type of report - refer to the documentation on the specific report type for more information.
For example, the [Test Report](./test.md) filter targets the linked [Stock Item](../stock/status.md) object, and can be used to select which stock items are allowed for the given report. Let's say that a certain test report should only be generated for "trackable" stock items. A filter could easily be constructed to accommodate this, by limiting available items to those where the associated [Part](../part/part.md) is *trackable*:
{% with id="report-filter-valid", url="report/filters_valid.png", description="Report filter selection" %}
{% include 'img.html' %}
{% endwith %}
If you enter an invalid option for the filter field, an error message will be displayed:
{% with id="report-filter-invalid", url="report/filters_invalid.png", description="Invalid filter selection" %}
{% include 'img.html' %}
{% endwith %}
!!! warning "Advanced Users"
Report filtering is an advanced topic, and requires a little bit of knowledge of the underlying data structure!
### Metadata
A JSON field made available to any [plugins](../extend/plugins.md) - but not used by internal code.
## Report Options
A number of global reporting options are available for customizing InvenTree reports:
{% with id="report-options", url="report/report.png", description="Report Options" %}
{% include 'img.html' %}
{% endwith %}
### Enable Reports
By default, the reporting feature is disabled. It must be enabled in the global settings.
### Default Page Size
The built-in InvenTree report templates (and any reports which are derived from the built-in templates) use the *Page Size* option to set the page size of the generated reports.
!!! info "Override Page Size"
Custom report templates do not have to make use of the *Page Size* option, although it is made available to the template context.
### Debug Mode
As templates are rendered directly to a PDF object, it can be difficult to debug problems when the PDF does not render exactly as expected.
Setting the *Debug Mode* option renders the template as raw HTML instead of PDF, allowing the rendering output to be introspected. This feature allows template designers to understand any issues with the generated HTML (before it is passed to the PDF generation engine).
!!! warning "HTML Rendering Limitations"
When rendered in debug mode, @page attributes (such as size, etc) will **not** be observed. Additionally, any asset files stored on the InvenTree server will not be rendered. Debug mode is not intended to produce "good looking" documents!
## 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 %}
<!-- Need to include the report template tags at the start of the template file -->
{% load report %}
<!-- Simple stylesheet -->
<head>
<style>
.company-logo {
height: 50px;
}
</style>
</head>
<body>
<!-- Report template code here -->
<!-- Render an uploaded asset image -->
<img src="{% asset 'company_image.png' %}" class="company-logo">
<!-- ... -->
</body>
{% 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"
You need to ensure your asset images to the report/assets directory in the [data directory](../start/intro.md#file-storage). Upload new assets via the [admin interface](../settings/admin.md) to ensure they are uploaded to the correct location on the server.
## 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 re-used 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.
Similar to assets files, snippet template files are uploaded via the admin interface.
Snippets are included in a template as follows:
```
{% raw %}{% include 'snippets/<snippet_name.html>' %}{% endraw %}
```
For example, consider a 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 %}
<table class='stock-table'>
<thead>
<!-- table header data -->
</thead>
<tbody>
{% for item in location.stock_items %}
{% include 'snippets/stock_row.html' with item=item %}
{% endfor %}
</tbody>
{% 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 %}
<!-- stock_row snippet -->
<tr>
<td>{{ item.part.full_name }}</td>
<td>{{ item.quantity }}</td>
</tr>
{% endraw %}
```

View File

@ -1,26 +0,0 @@
---
title: Return Order Reports
---
## Return Order Reports
Custom reports may be generated against any given [Return Order](../order/return_order.md). For example, return order reports can be used to generate an RMA request to send to a customer.
### Context Variables
In addition to the default report context variables, the following context variables are made available to the return order report template for rendering:
| Variable | Description |
| --- | --- |
| order | The return order object the report is being generated against |
| description | The description of the order, also accessed through `order.description` |
| reference | The reference of the order, also accessed through `order.reference` |
| customer | The customer object related to this order |
| lines | The list of line items linked to this order |
| extra_lines | The list of extra line items linked to this order |
### Default Report Template
A default report template is provided out of the box, which can be used as a starting point for developing custom return order report templates.
View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_return_order_report_base.html) for the default return order report template.

View File

@ -1,31 +0,0 @@
---
title: Sales Order Reports
---
## Sales Order Reports
Custom sales order reports may be generated against any given [Sales Order](../order/sales_order.md). For example, a sales order report could be used to generate an invoice to send to a customer.
### Sales Order Filters
The report template can be filtered against available [Sales Order](../order/sales_order.md) instances.
### Context Variables
In addition to the default report context variables, the following variables are made available to the sales order report template for rendering:
| Variable | Description |
| --- | --- |
| order | The specific Sales Order object |
| reference | The order reference field (can also be accessed as `{% raw %}{{ order.description }}{% endraw %}`) |
| description | The order description field |
| customer | The [customer](../order/company.md#customers) associated with the particular sales order |
| lines | A list of available line items for this order |
| extra_lines | A list of available *extra* line items for this order |
| order.currency | The currency code associated with this order, e.g. 'CAD' |
### Default Report Template
A default *Sales Order Report* template is provided out of the box, which is useful for generating simple test reports. Furthermore, it may be used as a starting point for developing custom BOM reports:
View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_so_report_base.html) for the default sales order report template.

View File

@ -0,0 +1,78 @@
---
title: Sample Templates
---
## Sample Templates
A number of pre-built templates are provided with InvenTree, which can be used as a starting point for creating custom reports and labels.
Users can create their own custom templates, or modify the provided templates to suit their needs.
## Report Templates
The following report templates are provided "out of the box" and can be used as a starting point, or as a reference for creating custom reports templates:
| Template | Model Type | Description |
| --- | --- | --- |
| [Bill of Materials](#bill-of-materials-report) | [Part](../part/part.md) | Bill of Materials report |
| [Build Order](#build-order) | [BuildOrder](../build/build.md) | Build Order report |
| [Purchase Order](#purchase-order) | [PurchaseOrder](../order/purchase_order.md) | Purchase Order report |
| [Return Order](#return-order) | [ReturnOrder](../order/return_order.md) | Return Order report |
| [Sales Order](#sales-order) | [SalesOrder](../order/sales_order.md) | Sales Order report |
| [Stock Location](#stock-location) | [StockLocation](../stock/stock.md#stock-location) | Stock Location report |
| [Test Report](#test-report) | [StockItem](../stock/stock.md#stock-item) | Test Report |
### Bill of Materials Report
{{ templatefile("report/inventree_bill_of_materials_report.html") }}
### Build Order
{{ templatefile("report/inventree_build_order_report.html") }}
### Purchase Order
{{ templatefile("report/inventree_bill_of_materials_report.html") }}
### Return Order
{{ templatefile("report/inventree_return_order_report.html") }}
### Sales Order
{{ templatefile("report/inventree_sales_order_report.html") }}
### Stock Location
{{ templatefile("report/inventree_stock_location_report.html") }}
### Test Report
{{ templatefile("report/inventree_test_report.html") }}
## Label Templates
The following label templates are provided "out of the box" and can be used as a starting point, or as a reference for creating custom label templates:
| Template | Model Type | Description |
| --- | --- | --- |
| [Build Line](#build-line-label) | [Build line item](../build/build.md) | Build Line label |
| [Part](#part-label) | [Part](../part/part.md) | Part label |
| [Stock Item](#stock-item-label) | [StockItem](../stock/stock.md#stock-item) | Stock Item label |
| [Stock Location](#stock-location-label) | [StockLocation](../stock/stock.md#stock-location) | Stock Location label |
### Build Line Label
{{ templatefile("label/buildline_label.html") }}
### Part Label
{{ templatefile("label/part_label_code128.html") }}
### Stock Item Label
{{ templatefile("label/stockitem_qr.html") }}
### Stock Location Label
{{ templatefile("label/stocklocation_qr_and_text.html") }}

View File

@ -1,16 +0,0 @@
---
title: Stock Location Reports
---
## Stock location Reports
You can print a formatted report of a stock location. This makes sense if you have several parts inside one location, e.g. a box that is sent out to a manufacturing partner. Whit a report you can create a box content list.
### Context Variables
You can use all content variables from the [StockLocation](./context_variables.md#stocklocation) object.
### Default Report Template
A default report template is provided out of the box, which can be used as a starting point for developing custom return order report templates.
View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_slr_report.html) for the default stock location report template.

View File

@ -0,0 +1,228 @@
---
title: InvenTree Templates
---
## Template Overview
InvenTree supports a customizable reporting ecosystem, allowing the user to develop document templates that meet their particular needs.
PDF files are generated from custom HTML template files which are written by the user.
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.
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.
## Template Types
The following types of templates are available:
### Reports
Reports are intended to serve as formal documents, and can be used to generate formatted PDF outputs for a variety of purposes.
Refer to the [report templates](./report.md) documentation for further information.
### Labels
Labels can also be generated using the templating system. Labels are intended to be used for printing small, formatted labels for items, parts, locations, etc.
Refer to the [label templates](./labels.md) documentation for further information.
### Template Model Types
When generating a particular template (to render a report or label output), the template is rendered against a particular "model" type. The model type determines the data that is available to the template, and how it is formatted.
To read more about the model types for which templates can be rendered, and the associated context information, refer to the [context variables](./context_variables.md) documentation.
### Default Reports
InvenTree is supplied with a number of default templates "out of the box" - for generating both labels and reports. These are generally quite simple, but serve as a starting point for building custom reports to suit a specific need.
!!! tip "Read the Source"
The source code for the default reports is [available on GitHub](https://github.com/inventree/InvenTree/tree/master/src/backend/InvenTree/report/templates/report). Use this as a guide for generating your own reports!
### Extending with Plugins
The [ReportMixin plugin class](../extend/plugins/report.md) allows reporting functionality to be extended with custom features.
## WeasyPrint Template Rendering
InvenTree report templates utilize the powerful [WeasyPrint](https://weasyprint.org/) PDF generation engine.
To read more about the capabilities of the report templating engine, and how to use it, refer to the [weasyprint documentation](./weasyprint.md).
## Creating Templates
Report and label templates can be created (and edited) via the [admin interface](../settings/admin.md), under the *Report* section.
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:
{% with id="report-list", url="report/report_template_admin.png", description="Report templates in admin interface" %}
{% include 'img.html' %}
{% endwith %}
!!! tip "Staff Access Only"
Only users with staff access can upload or edit report template files.
!!! 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.
### Name and Description
Each report template requires a name and description, which identify and describe the report template.
### 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.
### Filename Pattern
The filename pattern used to generate the output `.pdf` file. Defaults to "report.pdf".
The filename pattern allows custom rendering with any context variables which are available to the report. For example, a test report for a particular [Stock Item](../stock/stock.md#stock-item) can use the part name and serial number of the stock item when generating the report name:
{% with id="report-filename-pattern", url="report/filename_pattern.png", description="Report filename pattern" %}
{% include 'img.html' %}
{% endwith %}
### Template Filters
Each template instance provides a *filters* field, which can be used to filter which items a report or label template can be generated against. The target of the *filters* field depends on the model type associated with the particular template.
As an example, let's say that a certain `StockItem` report should only be generated for "trackable" stock items. A filter could easily be constructed to accommodate this, by limiting available items to those where the associated [Part](../part/part.md) is *trackable*:
{% with id="report-filter-valid", url="report/filters_valid.png", description="Report filter selection" %}
{% include 'img.html' %}
{% endwith %}
If you enter an invalid option for the filter field, an error message will be displayed:
{% with id="report-filter-invalid", url="report/filters_invalid.png", description="Invalid filter selection" %}
{% include 'img.html' %}
{% endwith %}
!!! warning "Advanced Users"
Report filtering is an advanced topic, and requires a little bit of knowledge of the underlying data structure!
### Metadata
A JSON field made available to any [plugins](../extend/plugins.md) - but not used by internal code.
## Reporting Options
A number of global reporting options are available for customizing InvenTree reports:
{% with id="report-options", url="report/report.png", description="Report Options" %}
{% include 'img.html' %}
{% endwith %}
### Enable Reports
By default, the reporting feature is disabled. It must be enabled in the global settings.
### Default Page Size
The built-in InvenTree report templates (and any reports which are derived from the built-in templates) use the *Page Size* option to set the page size of the generated reports.
!!! info "Override Page Size"
Custom report templates do not have to make use of the *Page Size* option, although it is made available to the template context.
### Debug Mode
As templates are rendered directly to a PDF object, it can be difficult to debug problems when the PDF does not render exactly as expected.
Setting the *Debug Mode* option renders the template as raw HTML instead of PDF, allowing the rendering output to be introspected. This feature allows template designers to understand any issues with the generated HTML (before it is passed to the PDF generation engine).
!!! warning "HTML Rendering Limitations"
When rendered in debug mode, @page attributes (such as size, etc) will **not** be observed. Additionally, any asset files stored on the InvenTree server will not be rendered. Debug mode is not intended to produce "good looking" documents!
## 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 %}
<!-- Need to include the report template tags at the start of the template file -->
{% load report %}
<!-- Simple stylesheet -->
<head>
<style>
.company-logo {
height: 50px;
}
</style>
</head>
<body>
<!-- Report template code here -->
<!-- Render an uploaded asset image -->
<img src="{% asset 'company_image.png' %}" class="company-logo">
<!-- ... -->
</body>
{% 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"
You need to ensure your asset images to the report/assets directory in the [data directory](../start/intro.md#file-storage). Upload new assets via the [admin interface](../settings/admin.md) to ensure they are uploaded to the correct location on the server.
## 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 re-used 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.
Similar to assets files, snippet template files are uploaded via the admin interface.
Snippets are included in a template as follows:
```
{% raw %}{% include 'snippets/<snippet_name.html>' %}{% endraw %}
```
For example, consider a 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 %}
<table class='stock-table'>
<thead>
<!-- table header data -->
</thead>
<tbody>
{% for item in location.stock_items %}
{% include 'snippets/stock_row.html' with item=item %}
{% endfor %}
</tbody>
{% 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 %}
<!-- stock_row snippet -->
<tr>
<td>{{ item.part.full_name }}</td>
<td>{{ item.quantity }}</td>
</tr>
{% endraw %}
```

View File

@ -1,87 +0,0 @@
---
title: Test Report
---
## Test Report
InvenTree provides [test result](../stock/test.md) tracking functionality which allows the users to keep track of any tests which have been performed on a given [stock item](../stock/stock.md).
Custom test reports may be generated against any given stock item. All testing data is made available to the template for custom rendering as required.
For example, an "Acceptance Test" report template may be customized to the particular device, with the results for certain tests rendering in a particular part of the page, with any tests which have not passed highlighted.
### Stock Item Filters
A TestReport template may define a set of filters against which stock items are sorted. Any [StockItem](../stock/stock.md) objects which match the provided filters can use the given TestReport.
This allows each TestReport to easily be assigned to a particular StockItem, or even multiple items.
In the example below, a test report template is uploaded and available to any stock items linked to a part with the name *"My Widget"*. Any combination of fields relevant to the StockItem model can be used here.
{% with id="test-report-filters", url="report/test_report_filters.png", description="Test report filters" %}
{% include 'img.html' %}
{% endwith %}
### Context Variables
In addition to the default report context variables, the following context variables are made available to the TestReport template for rendering:
| Variable | Description |
| --- | --- |
| stock_item | The individual [Stock Item](./context_variables.md#stockitem) object for which this test report is being generated |
| serial | The serial number of the linked Stock Item |
| part | The [Part](./context_variables.md#part) object of which the stock_item is an instance |
| parameters | A dict object representing the [parameters](../part/parameter.md) of the referenced part |
| test_keys | A list of the available 'keys' for the test results recorded against the stock item |
| test_template_list | A list of the available [test templates](../part/test.md#part-test-templates) for the referenced part |
| test_template_map | A map / dict of the available test templates |
| results | A dict of test result objects, where the 'key' for each test result is a shortened version of the test name (see below) |
| result_list | A list of each test result object |
| installed_items | A flattened list representing all [Stock Item](./context_variables.md#stockitem) objects which are *installed inside* the referenced [Stock Item](./context_variables.md#stockitem) object |
#### Results
The *results* context variable provides a very convenient method of callout out a particular test result by name.
#### Example
Say for example that a Part "Electronic Widget" has a stock item with serial number #123, and has a test result uploaded called "Firmware Checksum". The templated file can reference this data as follows:
``` html
<h3>Part: {% raw %}{{ part.name }}{% endraw %}</h3>
<b>Serial Number: {% raw %}{{ stock_item.serial }}{% endraw %}</b>
<hr>
<p>
Firmware Checksum: {% raw %}{{ results.firmwarechecksum.value }}.
Uploaded by {{ results.firmwarechecksum.user }}{% endraw %}
</p>
```
#### Installed Items
The *installed_items* context variable is a list of all [StockItem](./context_variables.md#stockitem) instances which are installed inside the [StockItem](./context_variables.md#stockitem) referenced by the report template. Each [StockItem](./context_variables.md#stockitem) can be dereferenced as follows:
```html
{% raw %}
<table>
{% for sub_item in installed_items %}
<tr>
<td>{{ sub_item.full_name }}</td>
<td>Serial Number: {{ sub_item.serial }}</td>
<td>Pass: {{ sub_item.passedAllRequiredTests }}</td>
</tr>
{% endfor %}
</table>
{% endraw %}
```
### Default Report Template
A default *Test Report* template is provided out of the box, which is useful for generating simple test reports. Furthermore, it may be used as a starting point for developing custom test reports:
{% with id="test-report-example", url="report/test_report_example.png", description="Example Test Report" %}
{% include "img.html" %}
{% endwith %}
View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_test_report_base.html) for the default test report template.

View File

@ -0,0 +1,111 @@
---
title: Weasyprint Templates
---
## WeasyPrint Templates
We use the powerful [WeasyPrint](https://weasyprint.org/) PDF generation engine to create custom reports and labels.
!!! 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.
### Stylesheets
Templates are rendered using standard HTML / CSS - if you are familiar with web page layout, you're ready to go!
### Template Language
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.
### Context Variables
!!! info "Context Variables"
Templates will have different variables available to them depending on the report type. Read the detailed information on each available report type for further information.
Please refer to the [Context variables](./context_variables.md) page.
### Conditional Rendering
The django template system allows for conditional rendering, providing conditional flow statements such as:
```
{% raw %}
{% if <condition> %}
{% do_something %}
{% elif <other_condition> %}
<!-- something else -->
{% else %}
<!-- finally -->
{% endif %}
{% endraw %}
```
```
{% raw %}
{% for <item> in <list> %}
Item: {{ item }}
{% endfor %}
{% endraw %}
```
!!! info "Conditionals"
Refer to the [django template language documentation]({% include "django.html" %}/ref/templates/language/) for more information.
### Localization Issues
Depending on your localization scheme, inputting raw numbers into the formatting section template can cause some unintended issues. Consider the block below which specifies the page size for a rendered template:
```html
{% raw %}
<head>
<style>
@page {
size: {{ width }}mm {{ height }}mm;
margin: 0mm;
}
</style>
</head>
{% endraw %}
```
If localization settings on the InvenTree server use a comma (`,`) character as a decimal separator, this may produce an output like:
```html
{% raw %}
{% endraw %}
<head>
<style>
@page {
size: 57,3mm 99,0mm;
margin: 0mm;
}
</style>
</head>
```
The resulting `{% raw %}<style>{% endraw %}` CSS block will be *invalid*!
So, if you are writing a template which has custom formatting, (or any other sections which cannot handle comma decimal separators) you must wrap that section in a `{% raw %}{% localize off %}{% endraw %}` block:
```html
{% raw %}
{% load l10n %}
<head>
<style>
@page {
{% localize off %}
size: {{ width }}mm {{ height }}mm;
{% endlocalize %}
margin: 0mm;
}
</style>
</head>
{% endraw %}
```
!!! tip "Close it out"
Don't forget to end with a `{% raw %}{% endlocalize %}{% endraw %}` tag!
!!! tip "l10n"
You will need to add `{% raw %}{% load l10n %}{% endraw %}` to the top of your template file to use the `{% raw %}{% localize %}{% endraw %}` tag.

View File

@ -171,4 +171,4 @@ The packages are provided by [packager.io](https://packager.io/). They are built
The package sets up [services](#controlling-inventree) that run the needed processes as the unprivileged user `inventree`. This keeps the privileges of InvenTree as low as possible.
A CLI is provided to interface with low-level management functions like [variable management](#enviroment-variables), log access, commands, process scaling, etc.
A CLI is provided to interface with low-level management functions like [variable management](#environment-variables), log access, commands, process scaling, etc.

View File

@ -1,6 +1,7 @@
"""Main entry point for the documentation build process."""
import os
import textwrap
def define_env(env):
@ -22,3 +23,29 @@ def define_env(env):
assets.append(os.path.join(subdir, asset))
return assets
@env.macro
def templatefile(filename):
"""Include code for a provided template file."""
here = os.path.dirname(__file__)
template_dir = os.path.join(
here, '..', 'src', 'backend', 'InvenTree', 'report', 'templates'
)
template_file = os.path.join(template_dir, filename)
template_file = os.path.abspath(template_file)
basename = os.path.basename(filename)
if not os.path.exists(template_file):
raise FileNotFoundError(f'Report template file {filename} does not exist.')
with open(template_file, 'r') as f:
content = f.read()
data = f'??? abstract "Template: {basename}"\n\n'
data += ' ```html\n'
data += textwrap.indent(content, ' ')
data += '\n\n'
data += ' ```\n\n'
return data

View File

@ -135,25 +135,15 @@ nav:
- Return Orders: order/return_order.md
- Project Codes: order/project_codes.md
- Report:
- Templates: report/report.md
- Templates: report/templates.md
- Template Rendering: report/weasyprint.md
- Template Editor: report/template_editor.md
- Report Types:
- Test Reports: report/test.md
- Build Order: report/build.md
- Purchase Order: report/purchase_order.md
- Sales Order: report/sales_order.md
- Return Order: report/return_order.md
- BOM: report/bom.md
- Stock Location: report/stock_location.md
- Labels:
- Custom Labels: report/labels.md
- Part Labels: report/labels/part_labels.md
- Stock Labels: report/labels/stock_labels.md
- Location Labels: report/labels/location_labels.md
- Build Labels: report/labels/build_labels.md
- Reports: report/report.md
- Labels: report/labels.md
- Context Variables: report/context_variables.md
- Helper Functions: report/helpers.md
- Barcodes: report/barcodes.md
- Context Variables: report/context_variables.md
- Sample Templates: report/samples.md
- Admin:
- Global Settings: settings/global.md
- User Settings: settings/user.md
@ -241,6 +231,7 @@ plugins:
on_config: "docs.docs.hooks:on_config"
- macros:
include_dir: docs/_includes
module_name: main
- mkdocstrings:
default_handler: python
handlers:
@ -250,6 +241,8 @@ plugins:
options:
show_symbol_type_heading: true
show_symbol_type_toc: true
show_root_heading: false
show_root_toc_entry: false
# Extensions
markdown_extensions:

View File

@ -5,4 +5,4 @@ mkdocs-git-revision-date-localized-plugin>=1.1,<2.0
mkdocs-simple-hooks>=0.1,<1.0
mkdocs-include-markdown-plugin
neoteroi-mkdocs
mkdocstrings[python]>=0.24.0
mkdocstrings[python]>=0.25.0

View File

@ -1,12 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 200
INVENTREE_API_VERSION = 201
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v201 - 2024-05-21 : https://github.com/inventree/InvenTree/pull/7074
- Major refactor of the report template / report printing interface
- This is a *breaking change* to the report template API
v200 - 2024-05-20 : https://github.com/inventree/InvenTree/pull/7000
- Adds API endpoint for generating custom batch codes
- Adds API endpoint for generating custom serial numbers

View File

@ -74,6 +74,7 @@ class InvenTreeConfig(AppConfig):
obsolete = [
'InvenTree.tasks.delete_expired_sessions',
'stock.tasks.delete_old_stock_items',
'label.tasks.cleanup_old_label_outputs',
]
try:
@ -83,7 +84,14 @@ class InvenTreeConfig(AppConfig):
# Remove any existing obsolete tasks
try:
Schedule.objects.filter(func__in=obsolete).delete()
obsolete_tasks = Schedule.objects.filter(func__in=obsolete)
if obsolete_tasks.exists():
logger.info(
'Removing %s obsolete background tasks', obsolete_tasks.count()
)
obsolete_tasks.delete()
except Exception:
logger.exception('Failed to remove obsolete tasks - database not ready')

View File

@ -121,6 +121,16 @@ class InvenTreeMetadata(SimpleMetadata):
serializer_info = super().get_serializer_info(serializer)
# Look for any dynamic fields which were not available when the serializer was instantiated
for field_name in serializer.Meta.fields:
if field_name in serializer_info:
# Already know about this one
continue
if hasattr(serializer, field_name):
field = getattr(serializer, field_name)
serializer_info[field_name] = self.get_field_info(field)
model_class = None
# Attributes to copy extra attributes from the model to the field (if they don't exist)
@ -264,7 +274,9 @@ class InvenTreeMetadata(SimpleMetadata):
# Introspect writable related fields
if field_info['type'] == 'field' and not field_info['read_only']:
# If the field is a PrimaryKeyRelatedField, we can extract the model from the queryset
if isinstance(field, serializers.PrimaryKeyRelatedField):
if isinstance(field, serializers.PrimaryKeyRelatedField) or issubclass(
field.__class__, serializers.PrimaryKeyRelatedField
):
model = field.queryset.model
else:
logger.debug(
@ -285,6 +297,9 @@ class InvenTreeMetadata(SimpleMetadata):
else:
field_info['api_url'] = model.get_api_url()
# Handle custom 'primary key' field
field_info['pk_field'] = getattr(field, 'pk_field', 'pk') or 'pk'
# Add more metadata about dependent fields
if field_info['type'] == 'dependent field':
field_info['depends_on'] = field.depends_on

View File

@ -193,7 +193,6 @@ INSTALLED_APPS = [
'common.apps.CommonConfig',
'company.apps.CompanyConfig',
'plugin.apps.PluginAppConfig', # Plugin app runs before all apps that depend on the isPluginRegistryLoaded function
'label.apps.LabelConfig',
'order.apps.OrderConfig',
'part.apps.PartConfig',
'report.apps.ReportConfig',
@ -434,12 +433,7 @@ ROOT_URLCONF = 'InvenTree.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
BASE_DIR.joinpath('templates'),
# Allow templates in the reporting directory to be accessed
MEDIA_ROOT.joinpath('report'),
MEDIA_ROOT.joinpath('label'),
],
'DIRS': [BASE_DIR.joinpath('templates'), MEDIA_ROOT.joinpath('report')],
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',

View File

@ -1065,7 +1065,8 @@ class TestVersionNumber(TestCase):
subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8'
).strip()
self.assertEqual(hash, version.inventreeCommitHash())
# On some systems the hash is a different length, so just check the first 6 characters
self.assertEqual(hash[:6], version.inventreeCommitHash()[:6])
d = (
str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8')

View File

@ -21,7 +21,6 @@ from sesame.views import LoginView
import build.api
import common.api
import company.api
import label.api
import machine.api
import order.api
import part.api
@ -104,7 +103,7 @@ apipatterns = [
path('stock/', include(stock.api.stock_api_urls)),
path('build/', include(build.api.build_api_urls)),
path('order/', include(order.api.order_api_urls)),
path('label/', include(label.api.label_api_urls)),
path('label/', include(report.api.label_api_urls)),
path('report/', include(report.api.report_api_urls)),
path('machine/', include(machine.api.machine_api_urls)),
path('user/', include(users.api.user_urls)),

View File

@ -18,7 +18,7 @@ class BuildResource(InvenTreeResource):
# TODO: 2022-05-12 - Need to investigate why this is the case!
class Meta:
"""Metaclass options"""
"""Metaclass options."""
models = Build
skip_unchanged = True
report_skipped = False

View File

@ -30,7 +30,7 @@ class BuildFilter(rest_filters.FilterSet):
"""Custom filterset for BuildList API endpoint."""
class Meta:
"""Metaclass options"""
"""Metaclass options."""
model = Build
fields = [
'parent',

View File

@ -23,6 +23,7 @@ class Migration(migrations.Migration):
],
options={
'unique_together': {('build', 'bom_item')},
'verbose_name': 'Build Order Line Item',
},
),
]

View File

@ -38,6 +38,7 @@ from common.notifications import trigger_notification, InvenTreeNotificationBodi
from plugin.events import trigger_event
import part.models
import report.mixins
import stock.models
import users.models
@ -45,7 +46,14 @@ import users.models
logger = logging.getLogger('inventree')
class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.PluginValidationMixin, InvenTree.models.ReferenceIndexingMixin, MPTTModel):
class Build(
report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin,
InvenTree.models.ReferenceIndexingMixin,
MPTTModel):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
Attributes:
@ -139,6 +147,21 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo
'part': _('Build order part cannot be changed')
})
def report_context(self) -> dict:
"""Generate custom report context data."""
return {
'bom_items': self.part.get_bom_items(),
'build': self,
'build_outputs': self.build_outputs.all(),
'line_items': self.build_lines.all(),
'part': self.part,
'quantity': self.quantity,
'reference': self.reference,
'title': str(self)
}
@staticmethod
def filterByDate(queryset, min_date, max_date):
"""Filter by 'minimum and maximum date range'.
@ -1291,7 +1314,7 @@ class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
class BuildLine(InvenTree.models.InvenTreeModel):
class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel):
"""A BuildLine object links a BOMItem to a Build.
When a new Build is created, the BuildLine objects are created automatically.
@ -1308,7 +1331,8 @@ class BuildLine(InvenTree.models.InvenTreeModel):
"""
class Meta:
"""Model meta options"""
"""Model meta options."""
verbose_name = _('Build Order Line Item')
unique_together = [
('build', 'bom_item'),
]
@ -1318,6 +1342,19 @@ class BuildLine(InvenTree.models.InvenTreeModel):
"""Return the API URL used to access this model"""
return reverse('api-build-line-list')
def report_context(self):
"""Generate custom report context for this BuildLine object."""
return {
'allocated_quantity': self.allocated_quantity,
'allocations': self.allocations,
'bom_item': self.bom_item,
'build': self.build,
'build_line': self,
'part': self.bom_item.sub_part,
'quantity': self.quantity,
}
build = models.ForeignKey(
Build, on_delete=models.CASCADE,
related_name='build_lines', help_text=_('Build object')
@ -1384,7 +1421,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
"""
class Meta:
"""Model meta options"""
"""Model meta options."""
unique_together = [
('build_line', 'stock_item', 'install_into'),
]

View File

@ -257,11 +257,7 @@ src="{% static 'img/blank_image.png' %}"
{% if report_enabled %}
$('#print-build-report').click(function() {
printReports({
items: [{{ build.pk }}],
key: 'build',
url: '{% url "api-build-report-list" %}',
});
printReports('build', [{{ build.pk }}]);
});
{% endif %}

View File

@ -55,7 +55,7 @@ def update_news_feed():
# Fetch and parse feed
try:
feed = requests.get(settings.INVENTREE_NEWS_URL)
feed = requests.get(settings.INVENTREE_NEWS_URL, timeout=30)
d = feedparser.parse(feed.content)
except Exception: # pragma: no cover
logger.warning('update_news_feed: Error parsing the newsfeed')

View File

@ -1,134 +0,0 @@
"""Shared templating code."""
import logging
import warnings
from pathlib import Path
from django.core.exceptions import AppRegistryNotReady
from django.core.files.storage import default_storage
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
import InvenTree.helpers
from InvenTree.config import ensure_dir
logger = logging.getLogger('inventree')
class TemplatingMixin:
"""Mixin that contains shared templating code."""
name: str = ''
db: str = ''
def __init__(self, *args, **kwargs):
"""Ensure that the required properties are set."""
super().__init__(*args, **kwargs)
if self.name == '':
raise NotImplementedError('ref must be set')
if self.db == '':
raise NotImplementedError('db must be set')
def create_defaults(self):
"""Function that creates all default templates for the app."""
raise NotImplementedError('create_defaults must be implemented')
def get_src_dir(self, ref_name):
"""Get the source directory for the default templates."""
raise NotImplementedError('get_src_dir must be implemented')
def get_new_obj_data(self, data, filename):
"""Get the data for a new template db object."""
raise NotImplementedError('get_new_obj_data must be implemented')
# Standardized code
def ready(self):
"""This function is called whenever the app is loaded."""
import InvenTree.ready
# skip loading if plugin registry is not loaded or we run in a background thread
if (
not InvenTree.ready.isPluginRegistryLoaded()
or not InvenTree.ready.isInMainThread()
):
return
if not InvenTree.ready.canAppAccessDatabase(allow_test=False):
return # pragma: no cover
with maintenance_mode_on():
try:
self.create_defaults()
except (
AppRegistryNotReady,
IntegrityError,
OperationalError,
ProgrammingError,
):
# Database might not yet be ready
warnings.warn(
f'Database was not ready for creating {self.name}s', stacklevel=2
)
set_maintenance_mode(False)
def create_template_dir(self, model, data):
"""Create folder and database entries for the default templates, if they do not already exist."""
ref_name = model.getSubdir()
# Create root dir for templates
src_dir = self.get_src_dir(ref_name)
ensure_dir(Path(self.name, 'inventree', ref_name), default_storage)
# Copy each template across (if required)
for entry in data:
self.create_template_file(model, src_dir, entry, ref_name)
def create_template_file(self, model, src_dir, data, ref_name):
"""Ensure a label template is in place."""
# Destination filename
filename = Path(self.name, 'inventree', ref_name, data['file'])
src_file = src_dir.joinpath(data['file'])
do_copy = False
if not default_storage.exists(filename):
logger.info("%s template '%s' is not present", self.name, filename)
do_copy = True
else:
# Check if the file contents are different
src_hash = InvenTree.helpers.hash_file(src_file)
dst_hash = InvenTree.helpers.hash_file(filename, default_storage)
if src_hash != dst_hash:
logger.info("Hash differs for '%s'", filename)
do_copy = True
if do_copy:
logger.info("Copying %s template '%s'", self.name, filename)
# Ensure destination dir exists
ensure_dir(filename.parent, default_storage)
# Copy file
default_storage.save(filename, src_file.open('rb'))
# Check if a file matching the template already exists
try:
if model.objects.filter(**{self.db: filename}).exists():
return # pragma: no cover
except Exception:
logger.exception(
"Failed to query %s for '%s' - you should run 'invoke update' first!",
self.name,
filename,
)
logger.info("Creating entry for %s '%s'", model, data.get('name'))
try:
model.objects.create(**self.get_new_obj_data(data, str(filename)))
except Exception as _e:
logger.warning(
"Failed to create %s '%s' with error '%s'", self.name, data['name'], _e
)

View File

@ -1,17 +0,0 @@
"""Admin functionality for the 'label' app."""
from django.contrib import admin
import label.models
class LabelAdmin(admin.ModelAdmin):
"""Admin class for the various label models."""
list_display = ('name', 'description', 'label', 'filters', 'enabled')
admin.site.register(label.models.StockItemLabel, LabelAdmin)
admin.site.register(label.models.StockLocationLabel, LabelAdmin)
admin.site.register(label.models.PartLabel, LabelAdmin)
admin.site.register(label.models.BuildLineLabel, LabelAdmin)

View File

@ -1,504 +0,0 @@
"""API functionality for the 'label' app."""
from django.core.exceptions import FieldError, ValidationError
from django.http import JsonResponse
from django.urls import include, path, re_path
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import cache_page, never_cache
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import serializers
from rest_framework.exceptions import NotFound
from rest_framework.request import clone_request
import build.models
import common.models
import InvenTree.exceptions
import InvenTree.helpers
import label.models
import label.serializers
from InvenTree.api import MetadataView
from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListCreateAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
from part.models import Part
from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin
from plugin.registry import registry
from stock.models import StockItem, StockLocation
class LabelFilterMixin:
"""Mixin for filtering a queryset by a list of object ID values.
Each implementing class defines a database model to lookup,
and a "key" (query parameter) for providing a list of ID (PK) values.
This mixin defines a 'get_items' method which provides a generic
implementation to return a list of matching database model instances.
"""
# Database model for instances to actually be "printed" against this label template
ITEM_MODEL = None
# Default key for looking up database model instances
ITEM_KEY = 'item'
def get_items(self):
"""Return a list of database objects from query parameter."""
ids = []
# Construct a list of possible query parameter value options
# e.g. if self.ITEM_KEY = 'part' -> ['part', 'part[]', 'parts', parts[]']
for k in [self.ITEM_KEY + x for x in ['', '[]', 's', 's[]']]:
if ids := self.request.query_params.getlist(k, []):
# Return the first list of matches
break
# Next we must validate each provided object ID
valid_ids = []
for id in ids:
try:
valid_ids.append(int(id))
except ValueError:
pass
# Filter queryset by matching ID values
return self.ITEM_MODEL.objects.filter(pk__in=valid_ids)
class LabelListView(LabelFilterMixin, ListCreateAPI):
"""Generic API class for label templates."""
def filter_queryset(self, queryset):
"""Filter the queryset based on the provided label ID values.
As each 'label' instance may optionally define its own filters,
the resulting queryset is the 'union' of the two.
"""
queryset = super().filter_queryset(queryset)
items = self.get_items()
if len(items) > 0:
"""
At this point, we are basically forced to be inefficient,
as we need to compare the 'filters' string of each label,
and see if it matches against each of the requested items.
TODO: In the future, if this becomes excessively slow, it
will need to be readdressed.
"""
valid_label_ids = set()
for lbl in queryset.all():
matches = True
try:
filters = InvenTree.helpers.validateFilterString(lbl.filters)
except ValidationError:
continue
for item in items:
item_query = self.ITEM_MODEL.objects.filter(pk=item.pk)
try:
if not item_query.filter(**filters).exists():
matches = False
break
except FieldError:
matches = False
break
# Matched all items
if matches:
valid_label_ids.add(lbl.pk)
else:
continue
# Reduce queryset to only valid matches
queryset = queryset.filter(pk__in=list(valid_label_ids))
return queryset
filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter]
filterset_fields = ['enabled']
search_fields = ['name', 'description']
@method_decorator(cache_page(5), name='dispatch')
class LabelPrintMixin(LabelFilterMixin):
"""Mixin for printing labels."""
rolemap = {'GET': 'view', 'POST': 'view'}
def check_permissions(self, request):
"""Override request method to GET so that also non superusers can print using a post request."""
if request.method == 'POST':
request = clone_request(request, 'GET')
return super().check_permissions(request)
@method_decorator(never_cache)
def dispatch(self, *args, **kwargs):
"""Prevent caching when printing report templates."""
return super().dispatch(*args, **kwargs)
def get_serializer(self, *args, **kwargs):
"""Define a get_serializer method to be discoverable by the OPTIONS request."""
# Check the request to determine if the user has selected a label printing plugin
plugin = self.get_plugin(self.request)
kwargs.setdefault('context', self.get_serializer_context())
serializer = plugin.get_printing_options_serializer(
self.request, *args, **kwargs
)
# if no serializer is defined, return an empty serializer
if not serializer:
return serializers.Serializer()
return serializer
def get(self, request, *args, **kwargs):
"""Perform a GET request against this endpoint to print labels."""
common.models.InvenTreeUserSetting.set_setting(
'DEFAULT_' + self.ITEM_KEY.upper() + '_LABEL_TEMPLATE',
self.get_object().pk,
None,
user=request.user,
)
return self.print(request, self.get_items())
def post(self, request, *args, **kwargs):
"""Perform a GET request against this endpoint to print labels."""
return self.get(request, *args, **kwargs)
def get_plugin(self, request):
"""Return the label printing plugin associated with this request.
This is provided in the url, e.g. ?plugin=myprinter
Requires:
- settings.PLUGINS_ENABLED is True
- matching plugin can be found
- matching plugin implements the 'labels' mixin
- matching plugin is enabled
"""
plugin_key = request.query_params.get('plugin', None)
# No plugin provided!
if plugin_key is None:
# Default to the builtin label printing plugin
plugin_key = InvenTreeLabelPlugin.NAME.lower()
plugin = registry.get_plugin(plugin_key)
if not plugin:
raise NotFound(f"Plugin '{plugin_key}' not found")
if not plugin.is_active():
raise ValidationError(f"Plugin '{plugin_key}' is not enabled")
if not plugin.mixin_enabled('labels'):
raise ValidationError(
f"Plugin '{plugin_key}' is not a label printing plugin"
)
# Only return the plugin if it is enabled and has the label printing mixin
return plugin
def print(self, request, items_to_print):
"""Print this label template against a number of pre-validated items."""
# Check the request to determine if the user has selected a label printing plugin
plugin = self.get_plugin(request)
if len(items_to_print) == 0:
# No valid items provided, return an error message
raise ValidationError('No valid objects provided to label template')
# Label template
label = self.get_object()
# Check the label dimensions
if label.width <= 0 or label.height <= 0:
raise ValidationError('Label has invalid dimensions')
# if the plugin returns a serializer, validate the data
if serializer := plugin.get_printing_options_serializer(
request, data=request.data, context=self.get_serializer_context()
):
serializer.is_valid(raise_exception=True)
# At this point, we offload the label(s) to the selected plugin.
# The plugin is responsible for handling the request and returning a response.
try:
result = plugin.print_labels(
label,
items_to_print,
request,
printing_options=(serializer.data if serializer else {}),
)
except ValidationError as e:
raise (e)
except Exception as e:
raise ValidationError([_('Error printing label'), str(e)])
if isinstance(result, JsonResponse):
result['plugin'] = plugin.plugin_slug()
return result
raise ValidationError(
f"Plugin '{plugin.plugin_slug()}' returned invalid response type '{type(result)}'"
)
class StockItemLabelMixin:
"""Mixin for StockItemLabel endpoints."""
queryset = label.models.StockItemLabel.objects.all()
serializer_class = label.serializers.StockItemLabelSerializer
ITEM_MODEL = StockItem
ITEM_KEY = 'item'
class StockItemLabelList(StockItemLabelMixin, LabelListView):
"""API endpoint for viewing list of StockItemLabel objects.
Filterable by:
- enabled: Filter by enabled / disabled status
- item: Filter by single stock item
- items: Filter by list of stock items
"""
pass
class StockItemLabelDetail(StockItemLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single StockItemLabel object."""
pass
class StockItemLabelPrint(StockItemLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockItemLabel object."""
pass
class StockLocationLabelMixin:
"""Mixin for StockLocationLabel endpoints."""
queryset = label.models.StockLocationLabel.objects.all()
serializer_class = label.serializers.StockLocationLabelSerializer
ITEM_MODEL = StockLocation
ITEM_KEY = 'location'
class StockLocationLabelList(StockLocationLabelMixin, LabelListView):
"""API endpoint for viewiing list of StockLocationLabel objects.
Filterable by:
- enabled: Filter by enabled / disabled status
- location: Filter by a single stock location
- locations: Filter by list of stock locations
"""
pass
class StockLocationLabelDetail(StockLocationLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single StockLocationLabel object."""
pass
class StockLocationLabelPrint(StockLocationLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockLocationLabel object."""
pass
class PartLabelMixin:
"""Mixin for PartLabel endpoints."""
queryset = label.models.PartLabel.objects.all()
serializer_class = label.serializers.PartLabelSerializer
ITEM_MODEL = Part
ITEM_KEY = 'part'
class PartLabelList(PartLabelMixin, LabelListView):
"""API endpoint for viewing list of PartLabel objects."""
pass
class PartLabelDetail(PartLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single PartLabel object."""
pass
class PartLabelPrint(PartLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a PartLabel object."""
pass
class BuildLineLabelMixin:
"""Mixin class for BuildLineLabel endpoints."""
queryset = label.models.BuildLineLabel.objects.all()
serializer_class = label.serializers.BuildLineLabelSerializer
ITEM_MODEL = build.models.BuildLine
ITEM_KEY = 'line'
class BuildLineLabelList(BuildLineLabelMixin, LabelListView):
"""API endpoint for viewing a list of BuildLineLabel objects."""
pass
class BuildLineLabelDetail(BuildLineLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single BuildLineLabel object."""
pass
class BuildLineLabelPrint(BuildLineLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a BuildLineLabel object."""
pass
label_api_urls = [
# Stock item labels
path(
'stock/',
include([
# Detail views
path(
'<int:pk>/',
include([
re_path(
r'print/?',
StockItemLabelPrint.as_view(),
name='api-stockitem-label-print',
),
path(
'metadata/',
MetadataView.as_view(),
{'model': label.models.StockItemLabel},
name='api-stockitem-label-metadata',
),
path(
'',
StockItemLabelDetail.as_view(),
name='api-stockitem-label-detail',
),
]),
),
# List view
path('', StockItemLabelList.as_view(), name='api-stockitem-label-list'),
]),
),
# Stock location labels
path(
'location/',
include([
# Detail views
path(
'<int:pk>/',
include([
re_path(
r'print/?',
StockLocationLabelPrint.as_view(),
name='api-stocklocation-label-print',
),
path(
'metadata/',
MetadataView.as_view(),
{'model': label.models.StockLocationLabel},
name='api-stocklocation-label-metadata',
),
path(
'',
StockLocationLabelDetail.as_view(),
name='api-stocklocation-label-detail',
),
]),
),
# List view
path(
'',
StockLocationLabelList.as_view(),
name='api-stocklocation-label-list',
),
]),
),
# Part labels
path(
'part/',
include([
# Detail views
path(
'<int:pk>/',
include([
re_path(
r'print/?',
PartLabelPrint.as_view(),
name='api-part-label-print',
),
path(
'metadata/',
MetadataView.as_view(),
{'model': label.models.PartLabel},
name='api-part-label-metadata',
),
path('', PartLabelDetail.as_view(), name='api-part-label-detail'),
]),
),
# List view
path('', PartLabelList.as_view(), name='api-part-label-list'),
]),
),
# BuildLine labels
path(
'buildline/',
include([
# Detail views
path(
'<int:pk>/',
include([
re_path(
r'print/?',
BuildLineLabelPrint.as_view(),
name='api-buildline-label-print',
),
path(
'metadata/',
MetadataView.as_view(),
{'model': label.models.BuildLineLabel},
name='api-buildline-label-metadata',
),
path(
'',
BuildLineLabelDetail.as_view(),
name='api-buildline-label-detail',
),
]),
),
# List view
path('', BuildLineLabelList.as_view(), name='api-buildline-label-list'),
]),
),
]

View File

@ -1,107 +0,0 @@
"""Config options for the label app."""
from pathlib import Path
from django.apps import AppConfig
from generic.templating.apps import TemplatingMixin
class LabelConfig(TemplatingMixin, AppConfig):
"""Configuration class for the "label" app."""
name = 'label'
db = 'label'
def create_defaults(self):
"""Create all default templates."""
# Test if models are ready
try:
import label.models
except Exception: # pragma: no cover
# Database is not ready yet
return
assert bool(label.models.StockLocationLabel is not None)
# Create the categories
self.create_template_dir(
label.models.StockItemLabel,
[
{
'file': 'qr.html',
'name': 'QR Code',
'description': 'Simple QR code label',
'width': 24,
'height': 24,
}
],
)
self.create_template_dir(
label.models.StockLocationLabel,
[
{
'file': 'qr.html',
'name': 'QR Code',
'description': 'Simple QR code label',
'width': 24,
'height': 24,
},
{
'file': 'qr_and_text.html',
'name': 'QR and text',
'description': 'Label with QR code and name of location',
'width': 50,
'height': 24,
},
],
)
self.create_template_dir(
label.models.PartLabel,
[
{
'file': 'part_label.html',
'name': 'Part Label',
'description': 'Simple part label',
'width': 70,
'height': 24,
},
{
'file': 'part_label_code128.html',
'name': 'Barcode Part Label',
'description': 'Simple part label with Code128 barcode',
'width': 70,
'height': 24,
},
],
)
self.create_template_dir(
label.models.BuildLineLabel,
[
{
'file': 'buildline_label.html',
'name': 'Build Line Label',
'description': 'Example build line label',
'width': 125,
'height': 48,
}
],
)
def get_src_dir(self, ref_name):
"""Get the source directory."""
return Path(__file__).parent.joinpath('templates', self.name, ref_name)
def get_new_obj_data(self, data, filename):
"""Get the data for a new template db object."""
return {
'name': data['name'],
'description': data['description'],
'label': filename,
'filters': '',
'enabled': True,
'width': data['width'],
'height': data['height'],
}

View File

@ -1,30 +0,0 @@
# Generated by Django 3.0.7 on 2020-08-15 23:27
import InvenTree.helpers
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='StockItemLabel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Label name', max_length=100, unique=True)),
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True)),
('label', models.FileField(help_text='Label template file', upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])])),
('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString])),
],
options={
'abstract': False,
},
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 3.0.7 on 2020-08-22 23:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='stockitemlabel',
name='enabled',
field=models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled'),
),
]

View File

@ -1,30 +0,0 @@
# Generated by Django 3.0.7 on 2021-01-08 12:06
import InvenTree.helpers
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0002_stockitemlabel_enabled'),
]
operations = [
migrations.CreateModel(
name='StockLocationLabel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Label name', max_length=100, unique=True)),
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True)),
('label', models.FileField(help_text='Label template file', upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])])),
('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString])),
('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')),
],
options={
'abstract': False,
},
),
]

View File

@ -1,56 +0,0 @@
# Generated by Django 3.0.7 on 2021-01-11 12:02
import InvenTree.helpers
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0003_stocklocationlabel'),
]
operations = [
migrations.AlterField(
model_name='stockitemlabel',
name='description',
field=models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='label',
field=models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='name',
field=models.CharField(help_text='Label name', max_length=100, verbose_name='Name'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='description',
field=models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='label',
field=models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='name',
field=models.CharField(help_text='Label name', max_length=100, verbose_name='Name'),
),
]

View File

@ -1,24 +0,0 @@
# Generated by Django 3.0.7 on 2021-01-13 12:02
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0004_auto_20210111_2302'),
]
operations = [
migrations.AlterField(
model_name='stockitemlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[label.models.validate_stock_location_filters], verbose_name='Filters'),
),
]

View File

@ -1,34 +0,0 @@
# Generated by Django 3.0.7 on 2021-02-22 04:35
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0005_auto_20210113_2302'),
]
operations = [
migrations.AddField(
model_name='stockitemlabel',
name='height',
field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'),
),
migrations.AddField(
model_name='stockitemlabel',
name='width',
field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='height',
field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='width',
field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 3.2 on 2021-05-13 03:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0006_auto_20210222_1535'),
]
operations = [
migrations.AddField(
model_name='stockitemlabel',
name='filename_pattern',
field=models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='filename_pattern',
field=models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern'),
),
]

View File

@ -1,37 +0,0 @@
# Generated by Django 3.2.4 on 2021-07-08 11:06
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0007_auto_20210513_1327'),
]
operations = [
migrations.CreateModel(
name='PartLabel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Label name', max_length=100, verbose_name='Name')),
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description')),
('label', models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label')),
('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')),
('width', models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]')),
('height', models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]')),
('filename_pattern', models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern')),
('filters', models.CharField(blank=True, help_text='Part query filters (comma-separated value of key=value pairs)', max_length=250, validators=[label.models.validate_part_filters], verbose_name='Filters')),
],
options={
'abstract': False,
},
),
migrations.AlterField(
model_name='stockitemlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs),', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'),
),
]

View File

@ -1,28 +0,0 @@
# Generated by Django 3.2.18 on 2023-03-17 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0008_auto_20210708_2106'),
]
operations = [
migrations.AddField(
model_name='partlabel',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='stockitemlabel',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='stocklocationlabel',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -1,33 +0,0 @@
# Generated by Django 3.2.19 on 2023-06-13 11:10
import django.core.validators
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0009_auto_20230317_0816'),
]
operations = [
migrations.CreateModel(
name='BuildLineLabel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')),
('name', models.CharField(help_text='Label name', max_length=100, verbose_name='Name')),
('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description')),
('label', models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label')),
('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')),
('width', models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]')),
('height', models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]')),
('filename_pattern', models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern')),
('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_build_line_filters], verbose_name='Filters')),
],
options={
'abstract': False,
},
),
]

View File

@ -1,29 +0,0 @@
# Generated by Django 3.2.19 on 2023-06-23 21:58
from django.db import migrations, models
import label.models
class Migration(migrations.Migration):
dependencies = [
('label', '0010_buildlinelabel'),
]
operations = [
migrations.AlterField(
model_name='partlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_part_filters], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stockitemlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'),
),
migrations.AlterField(
model_name='stocklocationlabel',
name='filters',
field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_stock_location_filters], verbose_name='Filters'),
),
]

View File

@ -1,26 +0,0 @@
# Generated by Django 3.2.20 on 2023-07-14 11:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import label.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('label', '0011_auto_20230623_2158'),
]
operations = [
migrations.CreateModel(
name='LabelOutput',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('label', models.FileField(unique=True, upload_to=label.models.rename_label_output)),
('created', models.DateField(auto_now_add=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -1,429 +0,0 @@
"""Label printing models."""
import logging
import os
import sys
from django.conf import settings
from django.contrib.auth.models import User
from django.core.validators import FileExtensionValidator, MinValueValidator
from django.db import models
from django.template import Context, Template
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import build.models
import InvenTree.helpers
import InvenTree.models
import part.models
import stock.models
from InvenTree.helpers import normalize, validateFilterString
from InvenTree.helpers_model import get_base_url
from plugin.registry import registry
try:
from django_weasyprint import WeasyTemplateResponseMixin
except OSError as err: # pragma: no cover
print(f'OSError: {err}')
print('You may require some further system packages to be installed.')
sys.exit(1)
logger = logging.getLogger('inventree')
def rename_label(instance, filename):
"""Place the label file into the correct subdirectory."""
filename = os.path.basename(filename)
return os.path.join('label', 'template', instance.SUBDIR, filename)
def rename_label_output(instance, filename):
"""Place the label output file into the correct subdirectory."""
filename = os.path.basename(filename)
return os.path.join('label', 'output', filename)
def validate_stock_item_filters(filters):
"""Validate query filters for the StockItemLabel model."""
filters = validateFilterString(filters, model=stock.models.StockItem)
return filters
def validate_stock_location_filters(filters):
"""Validate query filters for the StockLocationLabel model."""
filters = validateFilterString(filters, model=stock.models.StockLocation)
return filters
def validate_part_filters(filters):
"""Validate query filters for the PartLabel model."""
filters = validateFilterString(filters, model=part.models.Part)
return filters
def validate_build_line_filters(filters):
"""Validate query filters for the BuildLine model."""
filters = validateFilterString(filters, model=build.models.BuildLine)
return filters
class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
"""Class for rendering a label to a PDF."""
pdf_filename = 'label.pdf'
pdf_attachment = True
def __init__(self, request, template, **kwargs):
"""Initialize a label mixin with certain properties."""
self.request = request
self.template_name = template
self.pdf_filename = kwargs.get('filename', 'label.pdf')
class LabelTemplate(InvenTree.models.InvenTreeMetadataModel):
"""Base class for generic, filterable labels."""
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
abstract = True
@classmethod
def getSubdir(cls) -> str:
"""Return the subdirectory for this label."""
return cls.SUBDIR
# Each class of label files will be stored in a separate subdirectory
SUBDIR: str = 'label'
# Object we will be printing against (will be filled out later)
object_to_print = None
@property
def template(self):
"""Return the file path of the template associated with this label instance."""
return self.label.path
def __str__(self):
"""Format a string representation of a label instance."""
return f'{self.name} - {self.description}'
name = models.CharField(
blank=False, max_length=100, verbose_name=_('Name'), help_text=_('Label name')
)
description = models.CharField(
max_length=250,
blank=True,
null=True,
verbose_name=_('Description'),
help_text=_('Label description'),
)
label = models.FileField(
upload_to=rename_label,
unique=True,
blank=False,
null=False,
verbose_name=_('Label'),
help_text=_('Label template file'),
validators=[FileExtensionValidator(allowed_extensions=['html'])],
)
enabled = models.BooleanField(
default=True,
verbose_name=_('Enabled'),
help_text=_('Label template is enabled'),
)
width = models.FloatField(
default=50,
verbose_name=_('Width [mm]'),
help_text=_('Label width, specified in mm'),
validators=[MinValueValidator(2)],
)
height = models.FloatField(
default=20,
verbose_name=_('Height [mm]'),
help_text=_('Label height, specified in mm'),
validators=[MinValueValidator(2)],
)
filename_pattern = models.CharField(
default='label.pdf',
verbose_name=_('Filename Pattern'),
help_text=_('Pattern for generating label filenames'),
max_length=100,
)
@property
def template_name(self):
"""Returns the file system path to the template file.
Required for passing the file to an external process
"""
template = self.label.name
template = template.replace('/', os.path.sep)
template = template.replace('\\', os.path.sep)
template = settings.MEDIA_ROOT.joinpath(template)
return template
def get_context_data(self, request):
"""Supply custom context data to the template for rendering.
Note: Override this in any subclass
"""
return {} # pragma: no cover
def generate_filename(self, request, **kwargs):
"""Generate a filename for this label."""
template_string = Template(self.filename_pattern)
ctx = self.context(request)
context = Context(ctx)
return template_string.render(context)
def generate_page_style(self, **kwargs):
"""Generate @page style for the label template.
This is inserted at the top of the style block for a given label
"""
width = kwargs.get('width', self.width)
height = kwargs.get('height', self.height)
margin = kwargs.get('margin', 0)
return f"""
@page {{
size: {width}mm {height}mm;
margin: {margin}mm;
}}
"""
def context(self, request, **kwargs):
"""Provides context data to the template.
Arguments:
request: The HTTP request object
kwargs: Additional keyword arguments
"""
context = self.get_context_data(request)
# By default, each label is supplied with '@page' data
# However, it can be excluded, e.g. when rendering a label sheet
if kwargs.get('insert_page_style', True):
context['page_style'] = self.generate_page_style()
# Add "basic" context data which gets passed to every label
context['base_url'] = get_base_url(request=request)
context['date'] = InvenTree.helpers.current_date()
context['datetime'] = InvenTree.helpers.current_time()
context['request'] = request
context['user'] = request.user
context['width'] = self.width
context['height'] = self.height
# Pass the context through to any registered plugins
plugins = registry.with_mixin('report')
for plugin in plugins:
# Let each plugin add its own context data
plugin.add_label_context(self, self.object_to_print, request, context)
return context
def render_as_string(self, request, target_object=None, **kwargs):
"""Render the label to a HTML string."""
if target_object:
self.object_to_print = target_object
context = self.context(request, **kwargs)
return render_to_string(self.template_name, context, request)
def render(self, request, target_object=None, **kwargs):
"""Render the label template to a PDF file.
Uses django-weasyprint plugin to render HTML template
"""
if target_object:
self.object_to_print = target_object
context = self.context(request, **kwargs)
wp = WeasyprintLabelMixin(
request,
self.template_name,
base_url=request.build_absolute_uri('/'),
presentational_hints=True,
filename=self.generate_filename(request),
**kwargs,
)
return wp.render_to_response(context, **kwargs)
class LabelOutput(models.Model):
"""Class representing a label output file.
'Printing' a label may generate a file object (such as PDF)
which is made available for download.
Future work will offload this task to the background worker,
and provide a 'progress' bar for the user.
"""
# File will be stored in a subdirectory
label = models.FileField(
upload_to=rename_label_output, unique=True, blank=False, null=False
)
# Creation date of label output
created = models.DateField(auto_now_add=True, editable=False)
# User who generated the label
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
class StockItemLabel(LabelTemplate):
"""Template for printing StockItem labels."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the StockItemLabel model."""
return reverse('api-stockitem-label-list') # pragma: no cover
SUBDIR = 'stockitem'
filters = models.CharField(
blank=True,
max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs)'),
verbose_name=_('Filters'),
validators=[validate_stock_item_filters],
)
def get_context_data(self, request):
"""Generate context data for each provided StockItem."""
stock_item = self.object_to_print
return {
'item': stock_item,
'part': stock_item.part,
'name': stock_item.part.full_name,
'ipn': stock_item.part.IPN,
'revision': stock_item.part.revision,
'quantity': normalize(stock_item.quantity),
'serial': stock_item.serial,
'barcode_data': stock_item.barcode_data,
'barcode_hash': stock_item.barcode_hash,
'qr_data': stock_item.format_barcode(brief=True),
'qr_url': request.build_absolute_uri(stock_item.get_absolute_url()),
'tests': stock_item.testResultMap(),
'parameters': stock_item.part.parameters_map(),
}
class StockLocationLabel(LabelTemplate):
"""Template for printing StockLocation labels."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the StockLocationLabel model."""
return reverse('api-stocklocation-label-list') # pragma: no cover
SUBDIR = 'stocklocation'
filters = models.CharField(
blank=True,
max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs)'),
verbose_name=_('Filters'),
validators=[validate_stock_location_filters],
)
def get_context_data(self, request):
"""Generate context data for each provided StockLocation."""
location = self.object_to_print
return {'location': location, 'qr_data': location.format_barcode(brief=True)}
class PartLabel(LabelTemplate):
"""Template for printing Part labels."""
@staticmethod
def get_api_url():
"""Return the API url associated with the PartLabel model."""
return reverse('api-part-label-list') # pragma: no cover
SUBDIR = 'part'
filters = models.CharField(
blank=True,
max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs)'),
verbose_name=_('Filters'),
validators=[validate_part_filters],
)
def get_context_data(self, request):
"""Generate context data for each provided Part object."""
part = self.object_to_print
return {
'part': part,
'category': part.category,
'name': part.name,
'description': part.description,
'IPN': part.IPN,
'revision': part.revision,
'qr_data': part.format_barcode(brief=True),
'qr_url': request.build_absolute_uri(part.get_absolute_url()),
'parameters': part.parameters_map(),
}
class BuildLineLabel(LabelTemplate):
"""Template for printing labels against BuildLine objects."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the BuildLineLabel model."""
return reverse('api-buildline-label-list')
SUBDIR = 'buildline'
filters = models.CharField(
blank=True,
max_length=250,
help_text=_('Query filters (comma-separated list of key=value pairs)'),
verbose_name=_('Filters'),
validators=[validate_build_line_filters],
)
def get_context_data(self, request):
"""Generate context data for each provided BuildLine object."""
build_line = self.object_to_print
return {
'build_line': build_line,
'build': build_line.build,
'bom_item': build_line.bom_item,
'part': build_line.bom_item.sub_part,
'quantity': build_line.quantity,
'allocated_quantity': build_line.allocated_quantity,
'allocations': build_line.allocations,
}

View File

@ -1,67 +0,0 @@
"""API serializers for the label app."""
import label.models
from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer,
)
class LabelSerializerBase(InvenTreeModelSerializer):
"""Base class for label serializer."""
label = InvenTreeAttachmentSerializerField(required=True)
@staticmethod
def label_fields():
"""Generic serializer fields for a label template."""
return [
'pk',
'name',
'description',
'label',
'filters',
'width',
'height',
'enabled',
]
class StockItemLabelSerializer(LabelSerializerBase):
"""Serializes a StockItemLabel object."""
class Meta:
"""Metaclass options."""
model = label.models.StockItemLabel
fields = LabelSerializerBase.label_fields()
class StockLocationLabelSerializer(LabelSerializerBase):
"""Serializes a StockLocationLabel object."""
class Meta:
"""Metaclass options."""
model = label.models.StockLocationLabel
fields = LabelSerializerBase.label_fields()
class PartLabelSerializer(LabelSerializerBase):
"""Serializes a PartLabel object."""
class Meta:
"""Metaclass options."""
model = label.models.PartLabel
fields = LabelSerializerBase.label_fields()
class BuildLineLabelSerializer(LabelSerializerBase):
"""Serializes a BuildLineLabel object."""
class Meta:
"""Metaclass options."""
model = label.models.BuildLineLabel
fields = LabelSerializerBase.label_fields()

View File

@ -1,15 +0,0 @@
"""Background tasks for the label app."""
from datetime import timedelta
from django.utils import timezone
from InvenTree.tasks import ScheduledTask, scheduled_task
from label.models import LabelOutput
@scheduled_task(ScheduledTask.DAILY)
def cleanup_old_label_outputs():
"""Remove old label outputs from the database."""
# Remove any label outputs which are older than 30 days
LabelOutput.objects.filter(created__lte=timezone.now() - timedelta(days=5)).delete()

View File

@ -1,3 +0,0 @@
{% extends "label/buildline/buildline_label_base.html" %}
<!-- Refer to the buildline_label_base template for further information -->

View File

@ -1,301 +0,0 @@
"""Unit tests for label API."""
import json
from io import StringIO
from django.core.cache import cache
from django.urls import reverse
import label.models as label_models
from build.models import BuildLine
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part
from stock.models import StockItem, StockLocation
class LabelTest(InvenTreeAPITestCase):
"""Base class for unit testing label model API endpoints."""
fixtures = ['category', 'part', 'location', 'stock', 'bom', 'build']
superuser = True
model = None
list_url = None
detail_url = None
metadata_url = None
print_url = None
print_itemname = None
print_itemmodel = None
def setUp(self):
"""Ensure cache is cleared as part of test setup."""
cache.clear()
return super().setUp()
def test_api_url(self):
"""Test returned API Url against URL tag defined in this file."""
if not self.list_url:
return
self.assertEqual(reverse(self.list_url), self.model.get_api_url())
def test_list_endpoint(self):
"""Test that the LIST endpoint works for each model."""
if not self.list_url:
return
url = reverse(self.list_url)
response = self.get(url)
self.assertEqual(response.status_code, 200)
labels = self.model.objects.all()
n = len(labels)
# API endpoint must return correct number of reports
self.assertEqual(len(response.data), n)
# Filter by "enabled" status
response = self.get(url, {'enabled': True})
self.assertEqual(len(response.data), n)
response = self.get(url, {'enabled': False})
self.assertEqual(len(response.data), 0)
# Filter by "enabled" status
response = self.get(url, {'enabled': True})
self.assertEqual(len(response.data), 0)
response = self.get(url, {'enabled': False})
self.assertEqual(len(response.data), n)
def test_create_endpoint(self):
"""Test that creating a new report works for each label."""
if not self.list_url:
return
url = reverse(self.list_url)
# Create a new label
# Django REST API "APITestCase" does not work like requests - to send a file without it existing on disk,
# create it as a StringIO object, and upload it under parameter template
filestr = StringIO(
'{% extends "label/label_base.html" %}{% block content %}<pre>TEST LABEL</pre>{% endblock content %}'
)
filestr.name = 'ExampleTemplate.html'
response = self.post(
url,
data={
'name': 'New label',
'description': 'A fancy new label created through API test',
'label': filestr,
},
format=None,
expected_code=201,
)
# Make sure the expected keys are in the response
self.assertIn('pk', response.data)
self.assertIn('name', response.data)
self.assertIn('description', response.data)
self.assertIn('label', response.data)
self.assertIn('filters', response.data)
self.assertIn('enabled', response.data)
self.assertEqual(response.data['name'], 'New label')
self.assertEqual(
response.data['description'], 'A fancy new label created through API test'
)
self.assertEqual(response.data['label'].count('ExampleTemplate'), 1)
def test_detail_endpoint(self):
"""Test that the DETAIL endpoint works for each label."""
if not self.detail_url:
return
# Create an item first
self.test_create_endpoint()
labels = self.model.objects.all()
n = len(labels)
# Make sure at least one report defined
self.assertGreaterEqual(n, 1)
# Check detail page for first report
response = self.get(
reverse(self.detail_url, kwargs={'pk': labels[0].pk}), expected_code=200
)
# Make sure the expected keys are in the response
self.assertIn('pk', response.data)
self.assertIn('name', response.data)
self.assertIn('description', response.data)
self.assertIn('label', response.data)
self.assertIn('filters', response.data)
self.assertIn('enabled', response.data)
filestr = StringIO(
'{% extends "label/label_base.html" %}{% block content %}<pre>TEST LABEL</pre>{% endblock content %}'
)
filestr.name = 'ExampleTemplate_Updated.html'
# Check PATCH method
response = self.patch(
reverse(self.detail_url, kwargs={'pk': labels[0].pk}),
{
'name': 'Changed name during test',
'description': 'New version of the template',
'label': filestr,
},
format=None,
expected_code=200,
)
# Make sure the expected keys are in the response
self.assertIn('pk', response.data)
self.assertIn('name', response.data)
self.assertIn('description', response.data)
self.assertIn('label', response.data)
self.assertIn('filters', response.data)
self.assertIn('enabled', response.data)
self.assertEqual(response.data['name'], 'Changed name during test')
self.assertEqual(response.data['description'], 'New version of the template')
self.assertEqual(response.data['label'].count('ExampleTemplate_Updated'), 1)
def test_delete(self):
"""Test deleting, after other test are done."""
if not self.detail_url:
return
# Create an item first
self.test_create_endpoint()
labels = self.model.objects.all()
n = len(labels)
# Make sure at least one label defined
self.assertGreaterEqual(n, 1)
# Delete the last report
self.delete(
reverse(self.detail_url, kwargs={'pk': labels[n - 1].pk}), expected_code=204
)
def test_print_label(self):
"""Test printing a label."""
if not self.print_url:
return
# Create an item first
self.test_create_endpoint()
labels = self.model.objects.all()
n = len(labels)
# Make sure at least one label defined
self.assertGreaterEqual(n, 1)
url = reverse(self.print_url, kwargs={'pk': labels[0].pk})
# Try to print without providing a valid item
self.get(url, expected_code=400)
# Try to print with an invalid item
self.get(url, {self.print_itemname: 9999}, expected_code=400)
# Now print with a valid item
print(f'{self.print_itemmodel = }')
print(f'{self.print_itemmodel.objects.all() = }')
item = self.print_itemmodel.objects.first()
self.assertIsNotNone(item)
response = self.get(url, {self.print_itemname: item.pk}, expected_code=200)
response_json = json.loads(response.content.decode('utf-8'))
self.assertIn('file', response_json)
self.assertIn('success', response_json)
self.assertIn('message', response_json)
self.assertTrue(response_json['success'])
def test_metadata_endpoint(self):
"""Unit tests for the metadata field."""
if not self.metadata_url:
return
# Create an item first
self.test_create_endpoint()
labels = self.model.objects.all()
n = len(labels)
# Make sure at least one label defined
self.assertGreaterEqual(n, 1)
# Test getting metadata
response = self.get(
reverse(self.metadata_url, kwargs={'pk': labels[0].pk}), expected_code=200
)
self.assertEqual(response.data, {'metadata': {}})
class TestStockItemLabel(LabelTest):
"""Unit testing class for the StockItemLabel model."""
model = label_models.StockItemLabel
list_url = 'api-stockitem-label-list'
detail_url = 'api-stockitem-label-detail'
metadata_url = 'api-stockitem-label-metadata'
print_url = 'api-stockitem-label-print'
print_itemname = 'item'
print_itemmodel = StockItem
class TestStockLocationLabel(LabelTest):
"""Unit testing class for the StockLocationLabel model."""
model = label_models.StockLocationLabel
list_url = 'api-stocklocation-label-list'
detail_url = 'api-stocklocation-label-detail'
metadata_url = 'api-stocklocation-label-metadata'
print_url = 'api-stocklocation-label-print'
print_itemname = 'location'
print_itemmodel = StockLocation
class TestPartLabel(LabelTest):
"""Unit testing class for the PartLabel model."""
model = label_models.PartLabel
list_url = 'api-part-label-list'
detail_url = 'api-part-label-detail'
metadata_url = 'api-part-label-metadata'
print_url = 'api-part-label-print'
print_itemname = 'part'
print_itemmodel = Part
class TestBuildLineLabel(LabelTest):
"""Unit testing class for the BuildLine model."""
model = label_models.BuildLineLabel
list_url = 'api-buildline-label-list'
detail_url = 'api-buildline-label-detail'
metadata_url = 'api-buildline-label-metadata'
print_url = 'api-buildline-label-print'
print_itemname = 'line'
print_itemmodel = BuildLine

View File

@ -1,166 +0,0 @@
"""Tests for labels."""
import io
import json
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.http import JsonResponse
from django.urls import reverse
from common.models import InvenTreeSetting
from InvenTree.helpers import validateFilterString
from InvenTree.unit_test import InvenTreeAPITestCase
from label.models import LabelOutput
from part.models import Part
from plugin.registry import registry
from stock.models import StockItem
from .models import PartLabel, StockItemLabel, StockLocationLabel
class LabelTest(InvenTreeAPITestCase):
"""Unit test class for label models."""
fixtures = ['category', 'part', 'location', 'stock']
@classmethod
def setUpTestData(cls):
"""Ensure that some label instances exist as part of init routine."""
super().setUpTestData()
apps.get_app_config('label').create_defaults()
def test_default_labels(self):
"""Test that the default label templates are copied across."""
labels = StockItemLabel.objects.all()
self.assertGreater(labels.count(), 0)
labels = StockLocationLabel.objects.all()
self.assertGreater(labels.count(), 0)
def test_default_files(self):
"""Test that label files exist in the MEDIA directory."""
def test_subdir(ref_name):
item_dir = settings.MEDIA_ROOT.joinpath('label', 'inventree', ref_name)
self.assertGreater(len([item_dir.iterdir()]), 0)
test_subdir('stockitem')
test_subdir('stocklocation')
test_subdir('part')
def test_filters(self):
"""Test the label filters."""
filter_string = 'part__pk=10'
filters = validateFilterString(filter_string, model=StockItem)
self.assertEqual(type(filters), dict)
bad_filter_string = 'part_pk=10'
with self.assertRaises(ValidationError):
validateFilterString(bad_filter_string, model=StockItem)
def test_label_rendering(self):
"""Test label rendering."""
labels = PartLabel.objects.all()
part = Part.objects.first()
for label in labels:
url = reverse('api-part-label-print', kwargs={'pk': label.pk})
# Check that label printing returns the correct response type
response = self.get(f'{url}?parts={part.pk}', expected_code=200)
self.assertIsInstance(response, JsonResponse)
data = json.loads(response.content)
self.assertIn('message', data)
self.assertIn('file', data)
label_file = data['file']
self.assertIn('/media/label/output/', label_file)
def test_print_part_label(self):
"""Actually 'print' a label, and ensure that the correct information is contained."""
label_data = """
{% load barcode %}
{% load report %}
<html>
<!-- Test that the part instance is supplied -->
part: {{ part.pk }} - {{ part.name }}
<!-- Test qr data -->
data: {{ qr_data|safe }}
<!-- Test InvenTree URL -->
url: {{ qr_url|safe }}
<!-- Test image URL generation -->
image: {% part_image part width=128 %}
<!-- Test InvenTree logo -->
logo: {% logo_image %}
</html>
"""
buffer = io.StringIO()
buffer.write(label_data)
template = ContentFile(buffer.getvalue(), 'label.html')
# Construct a label template
label = PartLabel.objects.create(
name='test', description='Test label', enabled=True, label=template
)
# Ensure we are in "debug" mode (so the report is generated as HTML)
InvenTreeSetting.set_setting('REPORT_ENABLE', True, None)
# Set the 'debug' setting for the plugin
plugin = registry.get_plugin('inventreelabel')
plugin.set_setting('DEBUG', True)
# Print via the API (Note: will default to the builtin plugin if no plugin supplied)
url = reverse('api-part-label-print', kwargs={'pk': label.pk})
prt = Part.objects.first()
part_pk = prt.pk
part_name = prt.name
response = self.get(f'{url}?parts={part_pk}', expected_code=200)
data = json.loads(response.content)
self.assertIn('file', data)
# Find the generated file
output = LabelOutput.objects.last()
# Open the file and read data
with open(output.label.path, 'r') as f:
content = f.read()
# Test that each element has been rendered correctly
self.assertIn(f'part: {part_pk} - {part_name}', content)
self.assertIn(f'data: {{"part": {part_pk}}}', content)
if settings.ENABLE_CLASSIC_FRONTEND:
self.assertIn(f'http://testserver/part/{part_pk}/', content)
# Check that a encoded image has been generated
self.assertIn('data:image/png;charset=utf-8;base64,', content)
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [StockItemLabel, StockLocationLabel, PartLabel]:
p = model.objects.first()
self.assertIsNone(p.get_metadata('test'))
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
# Test update via the set_metadata() method
p.set_metadata('test', 3)
self.assertEqual(p.get_metadata('test'), 3)
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
p.set_metadata(k, k)
self.assertEqual(len(p.metadata.keys()), 4)

View File

@ -2,6 +2,7 @@
from typing import Union, cast
from django.db import models
from django.db.models.query import QuerySet
from django.http import HttpResponse, JsonResponse
from django.utils.translation import gettext_lazy as _
@ -10,10 +11,10 @@ from PIL.Image import Image
from rest_framework import serializers
from rest_framework.request import Request
from label.models import LabelTemplate
from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus
from plugin import registry as plg_registry
from plugin.base.label.mixins import LabelItemType, LabelPrintingMixin
from plugin.base.label.mixins import LabelPrintingMixin
from report.models import LabelTemplate
from stock.models import StockLocation
@ -32,7 +33,7 @@ class LabelPrinterBaseDriver(BaseDriver):
self,
machine: 'LabelPrinterMachine',
label: LabelTemplate,
item: LabelItemType,
item: models.Model,
request: Request,
**kwargs,
) -> None:
@ -56,7 +57,7 @@ class LabelPrinterBaseDriver(BaseDriver):
self,
machine: 'LabelPrinterMachine',
label: LabelTemplate,
items: QuerySet[LabelItemType],
items: QuerySet,
request: Request,
**kwargs,
) -> Union[None, JsonResponse]:
@ -83,7 +84,7 @@ class LabelPrinterBaseDriver(BaseDriver):
self.print_label(machine, label, item, request, **kwargs)
def get_printers(
self, label: LabelTemplate, items: QuerySet[LabelItemType], **kwargs
self, label: LabelTemplate, items: QuerySet, **kwargs
) -> list['LabelPrinterMachine']:
"""Get all printers that would be available to print this job.
@ -122,7 +123,7 @@ class LabelPrinterBaseDriver(BaseDriver):
return cast(LabelPrintingMixin, plg)
def render_to_pdf(
self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
) -> HttpResponse:
"""Helper method to render a label to PDF format for a specific item.
@ -137,7 +138,7 @@ class LabelPrinterBaseDriver(BaseDriver):
return response
def render_to_pdf_data(
self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
) -> bytes:
"""Helper method to render a label to PDF and return it as bytes for a specific item.
@ -153,7 +154,7 @@ class LabelPrinterBaseDriver(BaseDriver):
)
def render_to_html(
self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
) -> str:
"""Helper method to render a label to HTML format for a specific item.
@ -168,7 +169,7 @@ class LabelPrinterBaseDriver(BaseDriver):
return html
def render_to_png(
self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
) -> Image:
"""Helper method to render a label to PNG format for a specific item.

View File

@ -10,7 +10,6 @@ from django.urls import reverse
from rest_framework import serializers
from InvenTree.unit_test import InvenTreeAPITestCase
from label.models import PartLabel
from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus
from machine.machine_types.label_printer import LabelPrinterBaseDriver
from machine.models import MachineConfig
@ -18,6 +17,7 @@ from machine.registry import registry
from part.models import Part
from plugin.models import PluginConfig
from plugin.registry import registry as plg_registry
from report.models import LabelTemplate
class TestMachineRegistryMixin(TestCase):
@ -247,31 +247,33 @@ class TestLabelPrinterMachineType(TestMachineRegistryMixin, InvenTreeAPITestCase
plugin_ref = 'inventreelabelmachine'
# setup the label app
apps.get_app_config('label').create_defaults() # type: ignore
apps.get_app_config('report').create_default_labels() # type: ignore
plg_registry.reload_plugins()
config = cast(PluginConfig, plg_registry.get_plugin(plugin_ref).plugin_config()) # type: ignore
config.active = True
config.save()
parts = Part.objects.all()[:2]
label = cast(PartLabel, PartLabel.objects.first())
template = LabelTemplate.objects.filter(enabled=True, model_type='part').first()
url = reverse('api-part-label-print', kwargs={'pk': label.pk})
url += f'/?plugin={plugin_ref}&part[]={parts[0].pk}&part[]={parts[1].pk}'
url = reverse('api-label-print')
self.post(
url,
{
'plugin': config.key,
'items': [a.pk for a in parts],
'template': template.pk,
'machine': str(self.machine.pk),
'driver_options': {'copies': '1', 'test_option': '2'},
},
expected_code=200,
expected_code=201,
)
# test the print labels method call
self.print_labels.assert_called_once()
self.assertEqual(self.print_labels.call_args.args[0], self.machine.machine)
self.assertEqual(self.print_labels.call_args.args[1], label)
self.assertEqual(self.print_labels.call_args.args[1], template)
# TODO re-activate test
# self.assertQuerySetEqual(

View File

@ -30,6 +30,7 @@ class Migration(migrations.Migration):
],
options={
'abstract': False,
'verbose_name': 'Purchase Order'
},
),
migrations.CreateModel(

View File

@ -35,6 +35,7 @@ class Migration(migrations.Migration):
],
options={
'abstract': False,
'verbose_name': 'Sales Order',
},
),
migrations.AlterField(

View File

@ -39,6 +39,7 @@ class Migration(migrations.Migration):
],
options={
'abstract': False,
'verbose_name': 'Return Order',
},
),
migrations.AlterField(

View File

@ -30,6 +30,7 @@ import InvenTree.ready
import InvenTree.tasks
import InvenTree.validators
import order.validators
import report.mixins
import stock.models
import users.models as UserModels
from common.notifications import InvenTreeNotificationBodies
@ -185,6 +186,7 @@ class Order(
StateTransitionMixin,
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.ReferenceIndexingMixin,
InvenTree.models.InvenTreeModel,
@ -246,6 +248,17 @@ class Order(
'contact': _('Contact does not match selected company')
})
def report_context(self):
"""Generate context data for the reporting interface."""
return {
'description': self.description,
'extra_lines': self.extra_lines,
'lines': self.lines,
'order': self,
'reference': self.reference,
'title': str(self),
}
@classmethod
def overdue_filter(cls):
"""A generic implementation of an 'overdue' filter for the Model class.
@ -362,6 +375,15 @@ class PurchaseOrder(TotalPriceMixin, Order):
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE'
class Meta:
"""Model meta options."""
verbose_name = _('Purchase Order')
def report_context(self):
"""Return report context data for this PurchaseOrder."""
return {**super().report_context(), 'supplier': self.supplier}
def get_absolute_url(self):
"""Get the 'web' URL for this order."""
if settings.ENABLE_CLASSIC_FRONTEND:
@ -820,6 +842,15 @@ class SalesOrder(TotalPriceMixin, Order):
REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN'
REQUIRE_RESPONSIBLE_SETTING = 'SALESORDER_REQUIRE_RESPONSIBLE'
class Meta:
"""Model meta options."""
verbose_name = _('Sales Order')
def report_context(self):
"""Generate report context data for this SalesOrder."""
return {**super().report_context(), 'customer': self.customer}
def get_absolute_url(self):
"""Get the 'web' URL for this order."""
if settings.ENABLE_CLASSIC_FRONTEND:
@ -1977,6 +2008,15 @@ class ReturnOrder(TotalPriceMixin, Order):
REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN'
REQUIRE_RESPONSIBLE_SETTING = 'RETURNORDER_REQUIRE_RESPONSIBLE'
class Meta:
"""Model meta options."""
verbose_name = _('Return Order')
def report_context(self):
"""Generate report context data for this ReturnOrder."""
return {**super().report_context(), 'customer': self.customer}
def get_absolute_url(self):
"""Get the 'web' URL for this order."""
if settings.ENABLE_CLASSIC_FRONTEND:

View File

@ -253,11 +253,7 @@ $("#place-order").click(function() {
{% if report_enabled %}
$('#print-order-report').click(function() {
printReports({
items: [{{ order.pk }}],
key: 'order',
url: '{% url "api-po-report-list" %}',
});
printReports('purchaseorder', [{{ order.pk }}]);
});
{% endif %}

View File

@ -248,11 +248,7 @@ $('#cancel-order').click(function() {
{% if report_enabled %}
$('#print-order-report').click(function() {
printReports({
items: [{{ order.pk }}],
key: 'order',
url: '{% url "api-return-order-report-list" %}',
});
printReports('returnorder', [{{ order.pk }}]);
});
{% endif %}

View File

@ -310,11 +310,7 @@ $("#complete-order").click(function() {
{% if report_enabled %}
$('#print-order-report').click(function() {
printReports({
items: [{{ order.pk }}],
key: 'order',
url: '{% url "api-so-report-list" %}',
});
printReports('salesorder', [{{ order.pk }}]);
});
{% endif %}

View File

@ -7,7 +7,7 @@ import hashlib
import logging
import os
import re
from datetime import datetime, timedelta
from datetime import timedelta
from decimal import Decimal, InvalidOperation
from django.conf import settings
@ -43,6 +43,7 @@ import InvenTree.ready
import InvenTree.tasks
import part.helpers as part_helpers
import part.settings as part_settings
import report.mixins
import users.models
from build import models as BuildModels
from common.models import InvenTreeSetting
@ -340,6 +341,7 @@ class PartManager(TreeManager):
class Part(
InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin,
MPTTModel,
@ -409,8 +411,28 @@ class Part(
"""Return API query filters for limiting field results against this instance."""
return {'variant_of': {'exclude_tree': self.pk}}
def report_context(self):
"""Return custom report context information."""
return {
'bom_items': self.get_bom_items(),
'category': self.category,
'description': self.description,
'IPN': self.IPN,
'name': self.name,
'parameters': self.parameters_map(),
'part': self,
'qr_data': self.format_barcode(brief=True),
'qr_url': self.get_absolute_url(),
'revision': self.revision,
'test_template_list': self.getTestTemplates(),
'test_templates': self.getTestTemplates(),
}
def get_context_data(self, request, **kwargs):
"""Return some useful context data about this part for template rendering."""
"""Return some useful context data about this part for template rendering.
TODO: 2024-04-21 - Remove this method once the legacy UI code is removed
"""
context = {}
context['disabled'] = not self.active

View File

@ -432,6 +432,11 @@ class DuplicatePartSerializer(serializers.Serializer):
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']
part = serializers.PrimaryKeyRelatedField(
queryset=Part.objects.all(),
label=_('Original Part'),
@ -471,6 +476,11 @@ class DuplicatePartSerializer(serializers.Serializer):
class InitialStockSerializer(serializers.Serializer):
"""Serializer for creating initial stock quantity."""
class Meta:
"""Metaclass options."""
fields = ['quantity', 'location']
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
@ -494,6 +504,11 @@ class InitialStockSerializer(serializers.Serializer):
class InitialSupplierSerializer(serializers.Serializer):
"""Serializer for adding initial supplier / manufacturer information."""
class Meta:
"""Metaclass options."""
fields = ['supplier', 'sku', 'manufacturer', 'mpn']
supplier = serializers.PrimaryKeyRelatedField(
queryset=company.models.Company.objects.all(),
label=_('Supplier'),

View File

@ -629,11 +629,7 @@
{% if report_enabled %}
$("#print-bom-report").click(function() {
printReports({
items: [{{ part.pk }}],
key: 'part',
url: '{% url "api-bom-report-list" %}'
});
printReports('part', [{{ part.pk }}]);
});
{% endif %}
});

View File

@ -468,9 +468,8 @@
$('#print-label').click(function() {
printLabels({
items: [{{ part.pk }}],
key: 'part',
model_type: 'part',
singular_name: 'part',
url: '{% url "api-part-label-list" %}',
});
});
{% endif %}

View File

@ -47,3 +47,16 @@ class ReportMixin:
context: The context dictionary to add to
"""
pass
def report_callback(self, template, instance, report, request):
"""Callback function called after a report is generated.
Arguments:
template: The ReportTemplate model
instance: The instance of the target model
report: The generated report object
request: The initiating request object
The default implementation does nothing.
"""
pass

View File

@ -11,17 +11,12 @@ import pdf2image
from rest_framework import serializers
from rest_framework.request import Request
from build.models import BuildLine
from common.models import InvenTreeSetting
from InvenTree.exceptions import log_error
from InvenTree.tasks import offload_task
from label.models import LabelTemplate
from part.models import Part
from plugin.base.label import label as plugin_label
from plugin.helpers import MixinNotImplementedError
from stock.models import StockItem, StockLocation
LabelItemType = Union[StockItem, StockLocation, Part, BuildLine]
from report.models import LabelTemplate, TemplateOutput
class LabelPrintingMixin:
@ -34,11 +29,6 @@ class LabelPrintingMixin:
Note that the print_labels() function can also be overridden to provide custom behavior.
"""
# If True, the print_label() method will block until the label is printed
# If False, the offload_label() method will be called instead
# By default, this is False, which means that labels will be printed in the background
BLOCKING_PRINT = False
class MixinMeta:
"""Meta options for this mixin."""
@ -49,37 +39,42 @@ class LabelPrintingMixin:
super().__init__()
self.add_mixin('labels', True, __class__)
def render_to_pdf(self, label: LabelTemplate, request, **kwargs):
BLOCKING_PRINT = True
def render_to_pdf(self, label: LabelTemplate, instance, request, **kwargs):
"""Render this label to PDF format.
Arguments:
label: The LabelTemplate object to render
label: The LabelTemplate object to render against
instance: The model instance to render
request: The HTTP request object which triggered this print job
"""
try:
return label.render(request)
return label.render(instance, request)
except Exception:
log_error('label.render_to_pdf')
raise ValidationError(_('Error rendering label to PDF'))
def render_to_html(self, label: LabelTemplate, request, **kwargs):
def render_to_html(self, label: LabelTemplate, instance, request, **kwargs):
"""Render this label to HTML format.
Arguments:
label: The LabelTemplate object to render
label: The LabelTemplate object to render against
instance: The model instance to render
request: The HTTP request object which triggered this print job
"""
try:
return label.render_as_string(request)
return label.render_as_string(instance, request)
except Exception:
log_error('label.render_to_html')
raise ValidationError(_('Error rendering label to HTML'))
def render_to_png(self, label: LabelTemplate, request=None, **kwargs):
def render_to_png(self, label: LabelTemplate, instance, request=None, **kwargs):
"""Render this label to PNG format.
Arguments:
label: The LabelTemplate object to render
label: The LabelTemplate object to render against
item: The model instance to render
request: The HTTP request object which triggered this print job
Keyword Arguments:
pdf_data: The raw PDF data of the rendered label (if already rendered)
@ -94,7 +89,9 @@ class LabelPrintingMixin:
if not pdf_data:
pdf_data = (
self.render_to_pdf(label, request, **kwargs).get_document().write_pdf()
self.render_to_pdf(label, instance, request, **kwargs)
.get_document()
.write_pdf()
)
pdf2image_kwargs = {
@ -108,19 +105,21 @@ class LabelPrintingMixin:
return pdf2image.convert_from_bytes(pdf_data, **pdf2image_kwargs)[0]
except Exception:
log_error('label.render_to_png')
raise ValidationError(_('Error rendering label to PNG'))
return None
def print_labels(
self,
label: LabelTemplate,
items: QuerySet[LabelItemType],
output: TemplateOutput,
items: list,
request: Request,
**kwargs,
):
) -> None:
"""Print one or more labels with the provided template and items.
Arguments:
label: The LabelTemplate object to use for printing
output: The TemplateOutput 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
@ -128,7 +127,10 @@ class LabelPrintingMixin:
printing_options: The printing options set for this print job defined in the PrintingOptionsSerializer
Returns:
A JSONResponse object which indicates outcome to the user
None. Output data should be stored in the provided TemplateOutput object
Raises:
ValidationError if there is an error during the print process
The default implementation simply calls print_label() for each label, producing multiple single label output "jobs"
but this can be overridden by the particular plugin.
@ -138,19 +140,30 @@ class LabelPrintingMixin:
except AttributeError:
user = None
# Initial state for the output print job
output.progress = 0
output.complete = False
output.save()
N = len(items)
# Generate a label output for each provided item
for item in items:
label.object_to_print = item
filename = label.generate_filename(request)
pdf_file = self.render_to_pdf(label, request, **kwargs)
context = label.get_context(item, request)
filename = label.generate_filename(context)
pdf_file = self.render_to_pdf(label, item, request, **kwargs)
pdf_data = pdf_file.get_document().write_pdf()
png_file = self.render_to_png(label, request, pdf_data=pdf_data, **kwargs)
png_file = self.render_to_png(
label, item, request, pdf_data=pdf_data, **kwargs
)
print_args = {
'pdf_file': pdf_file,
'pdf_data': pdf_data,
'png_file': png_file,
'filename': filename,
'context': context,
'output': output,
'label_instance': label,
'item_instance': item,
'user': user,
@ -160,19 +173,34 @@ class LabelPrintingMixin:
}
if self.BLOCKING_PRINT:
# Blocking print job
# Print the label (blocking)
self.print_label(**print_args)
else:
# Non-blocking print job
# Offload the print task to the background worker
# Exclude the 'pdf_file' object - cannot be pickled
# Offload the print job to a background worker
self.offload_label(**print_args)
kwargs.pop('pdf_file', None)
offload_task(plugin_label.print_label, self.plugin_slug(), **print_args)
# Return a JSON response to the user
return JsonResponse({
'success': True,
'message': f'{len(items)} labels printed',
})
# Update the progress of the print job
output.progress += int(100 / N)
output.save()
# Mark the output as complete
output.complete = True
output.progress = 100
# Add in the generated file (if applicable)
output.output = self.get_generated_file(**print_args)
output.save()
def get_generated_file(self, **kwargs):
"""Return the generated file for download (or None, if this plugin does not generate a file output).
The default implementation returns None, but this can be overridden by the particular plugin.
"""
return None
def print_label(self, **kwargs):
"""Print a single label (blocking).
@ -183,6 +211,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
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)
@ -195,19 +224,6 @@ class LabelPrintingMixin:
'This Plugin must implement a `print_label` method'
)
def offload_label(self, **kwargs):
"""Offload a single label (non-blocking).
Instead of immediately printing the label (which is a blocking process),
this method should offload the label to a background worker process.
Offloads a call to the 'print_label' method (of this plugin) to a background worker.
"""
# Exclude the 'pdf_file' object - cannot be pickled
kwargs.pop('pdf_file', None)
offload_task(plugin_label.print_label, self.plugin_slug(), **kwargs)
def get_printing_options_serializer(
self, request: Request, *args, **kwargs
) -> Union[serializers.Serializer, None]:
@ -227,3 +243,11 @@ class LabelPrintingMixin:
return None
return serializer(*args, **kwargs)
def before_printing(self):
"""Hook method called before printing labels."""
pass
def after_printing(self):
"""Hook method called after printing labels."""
pass

View File

@ -12,62 +12,28 @@ from PIL import Image
from InvenTree.settings import BASE_DIR
from InvenTree.unit_test import InvenTreeAPITestCase
from label.models import PartLabel, StockItemLabel, StockLocationLabel
from part.models import Part
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
class LabelMixinTests(InvenTreeAPITestCase):
class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
"""Test that the Label mixin operates correctly."""
fixtures = ['category', 'part', 'location', 'stock']
roles = 'all'
plugin_ref = 'samplelabelprinter'
def do_activate_plugin(self):
"""Activate the 'samplelabel' plugin."""
config = registry.get_plugin('samplelabelprinter').plugin_config()
config.active = True
config.save()
def do_url(
self,
parts,
plugin_ref,
label,
url_name: str = 'api-part-label-print',
url_single: str = 'part',
invalid: bool = False,
):
"""Generate an URL to print a label."""
# Construct URL
kwargs = {}
if label:
kwargs['pk'] = label.pk
url = reverse(url_name, kwargs=kwargs)
# Append part filters
if not parts:
pass
elif len(parts) == 1:
url += f'?{url_single}={parts[0].pk}'
elif len(parts) > 1:
url += '?' + '&'.join([f'{url_single}s={item.pk}' for item in parts])
# Append an invalid item
if invalid:
url += f'&{url_single}{"s" if len(parts) > 1 else ""}=abc'
# Append plugin reference
if plugin_ref:
url += f'&plugin={plugin_ref}'
return url
@property
def printing_url(self):
"""Return the label printing URL."""
return reverse('api-label-print')
def test_wrong_implementation(self):
"""Test that a wrong implementation raises an error."""
@ -121,52 +87,106 @@ class LabelMixinTests(InvenTreeAPITestCase):
def test_printing_process(self):
"""Test that a label can be printed."""
# Ensure the labels were created
apps.get_app_config('label').create_defaults()
apps.get_app_config('report').create_default_labels()
apps.get_app_config('report').create_default_reports()
test_path = BASE_DIR / '_testfolder' / 'label'
# Lookup references
part = Part.objects.first()
parts = Part.objects.all()[:2]
plugin_ref = 'samplelabelprinter'
label = PartLabel.objects.first()
url = self.do_url([part], plugin_ref, label)
template = LabelTemplate.objects.filter(enabled=True, model_type='part').first()
# Non-existing plugin
response = self.get(f'{url}123', expected_code=404)
self.assertIn(
f"Plugin '{plugin_ref}123' not found", str(response.content, 'utf8')
self.assertIsNotNone(template)
self.assertTrue(template.enabled)
url = self.printing_url
# Template does not exist
response = self.post(
url, {'template': 9999, 'plugin': 9999, 'items': []}, expected_code=400
)
# Inactive plugin
response = self.get(url, expected_code=400)
self.assertIn(
f"Plugin '{plugin_ref}' is not enabled", str(response.content, 'utf8')
self.assertIn('object does not exist', str(response.data['template']))
self.assertIn('list may not be empty', str(response.data['items']))
# Plugin is not a label plugin
no_valid_plg = registry.get_plugin('digikeyplugin').plugin_config()
response = self.post(
url,
{'template': template.pk, 'plugin': no_valid_plg.key, 'items': [1, 2, 3]},
expected_code=400,
)
self.assertIn('Plugin does not support label printing', str(response.data))
# Find available plugins
plugins = registry.with_mixin('labels')
self.assertGreater(len(plugins), 0)
plugin = registry.get_plugin('samplelabelprinter')
config = plugin.plugin_config()
# Ensure that the plugin is not active
registry.set_plugin_state(plugin.slug, False)
# Plugin is not active - should return error
response = self.post(
url,
{'template': template.pk, 'plugin': config.key, 'items': [1, 2, 3]},
expected_code=400,
)
self.assertIn('Plugin is not active', str(response.data['plugin']))
# Active plugin
self.do_activate_plugin()
# Print one part
self.get(url, expected_code=200)
response = self.post(
url,
{'template': template.pk, 'plugin': config.key, 'items': [parts[0].pk]},
expected_code=201,
)
self.assertEqual(response.data['plugin'], 'samplelabelprinter')
self.assertIsNone(response.data['output'])
# Print multiple parts
self.get(self.do_url(parts, plugin_ref, label), expected_code=200)
response = self.post(
url,
{
'template': template.pk,
'plugin': config.key,
'items': [item.pk for item in parts],
},
expected_code=201,
)
self.assertEqual(response.data['plugin'], 'samplelabelprinter')
self.assertIsNone(response.data['output'])
# Print multiple parts without a plugin
self.get(self.do_url(parts, None, label), expected_code=200)
response = self.post(
url,
{'template': template.pk, 'items': [item.pk for item in parts]},
expected_code=201,
)
# Print multiple parts without a plugin in debug mode
response = self.get(self.do_url(parts, None, label), expected_code=200)
self.assertEqual(response.data['plugin'], 'inventreelabel')
self.assertIsNotNone(response.data['output'])
data = json.loads(response.content)
self.assertIn('file', data)
self.assertIn('output', data)
# Print no part
self.get(self.do_url(None, plugin_ref, label), expected_code=400)
self.post(
url,
{'template': template.pk, 'plugin': plugin.pk, 'items': None},
expected_code=400,
)
# Test that the labels have been printed
# The sample labelling plugin simply prints to file
test_path = BASE_DIR / '_testfolder' / 'label'
self.assertTrue(os.path.exists(f'{test_path}.pdf'))
# Read the raw .pdf data - ensure it contains some sensible information
@ -183,27 +203,30 @@ class LabelMixinTests(InvenTreeAPITestCase):
def test_printing_options(self):
"""Test printing options."""
# Ensure the labels were created
apps.get_app_config('label').create_defaults()
apps.get_app_config('report').create_default_labels()
# Lookup references
parts = Part.objects.all()[:2]
plugin_ref = 'samplelabelprinter'
label = PartLabel.objects.first()
template = LabelTemplate.objects.filter(enabled=True, model_type='part').first()
self.do_activate_plugin()
plugin = registry.get_plugin(self.plugin_ref)
# test options response
options = self.options(
self.do_url(parts, plugin_ref, label), expected_code=200
self.printing_url, data={'plugin': plugin.slug}, expected_code=200
).json()
self.assertIn('amount', options['actions']['POST'])
plg = registry.get_plugin(plugin_ref)
with mock.patch.object(plg, 'print_label') as print_label:
with mock.patch.object(plugin, 'print_label') as print_label:
# wrong value type
res = self.post(
self.do_url(parts, plugin_ref, label),
data={'amount': '-no-valid-int-'},
self.printing_url,
{
'plugin': plugin.slug,
'template': template.pk,
'items': [a.pk for a in parts],
'amount': '-no-valid-int-',
},
expected_code=400,
).json()
self.assertIn('amount', res)
@ -211,9 +234,14 @@ class LabelMixinTests(InvenTreeAPITestCase):
# correct value type
self.post(
self.do_url(parts, plugin_ref, label),
data={'amount': 13},
expected_code=200,
self.printing_url,
{
'template': template.pk,
'plugin': plugin.slug,
'items': [a.pk for a in parts],
'amount': 13,
},
expected_code=201,
).json()
self.assertEqual(
print_label.call_args.kwargs['printing_options'], {'amount': 13}
@ -221,57 +249,15 @@ class LabelMixinTests(InvenTreeAPITestCase):
def test_printing_endpoints(self):
"""Cover the endpoints not covered by `test_printing_process`."""
plugin_ref = 'samplelabelprinter'
# Activate the label components
apps.get_app_config('label').create_defaults()
apps.get_app_config('report').create_default_labels()
self.do_activate_plugin()
def run_print_test(label, qs, url_name, url_single):
"""Run tests on single and multiple page printing.
# Test StockItemLabel
self.run_print_test(StockItem, 'stockitem')
Args:
label: class of the label
qs: class of the base queryset
url_name: url for endpoints
url_single: item lookup reference
"""
label = label.objects.first()
qs = qs.objects.all()
# Test StockLocationLabel
self.run_print_test(StockLocation, 'stocklocation')
# List endpoint
self.get(
self.do_url(None, None, None, f'{url_name}-list', url_single),
expected_code=200,
)
# List endpoint with filter
self.get(
self.do_url(
qs[:2], None, None, f'{url_name}-list', url_single, invalid=True
),
expected_code=200,
)
# Single page printing
self.get(
self.do_url(qs[:1], plugin_ref, label, f'{url_name}-print', url_single),
expected_code=200,
)
# Multi page printing
self.get(
self.do_url(qs[:2], plugin_ref, label, f'{url_name}-print', url_single),
expected_code=200,
)
# Test StockItemLabels
run_print_test(StockItemLabel, StockItem, 'api-stockitem-label', 'item')
# Test StockLocationLabels
run_print_test(
StockLocationLabel, StockLocation, 'api-stocklocation-label', 'location'
)
# Test PartLabels
run_print_test(PartLabel, Part, 'api-part-label', 'part')
# Test PartLabel
self.run_print_test(Part, 'part')

View File

@ -1,10 +1,9 @@
"""Default label printing plugin (supports PDF generation)."""
from django.core.files.base import ContentFile
from django.http import JsonResponse
from django.utils.translation import gettext_lazy as _
from label.models import LabelOutput, LabelTemplate
from InvenTree.helpers import str2bool
from plugin import InvenTreePlugin
from plugin.mixins import LabelPrintingMixin, SettingsMixin
@ -19,7 +18,7 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin):
NAME = 'InvenTreeLabel'
TITLE = _('InvenTree PDF label printer')
DESCRIPTION = _('Provides native support for printing PDF labels')
VERSION = '1.0.0'
VERSION = '1.1.0'
AUTHOR = _('InvenTree contributors')
BLOCKING_PRINT = True
@ -33,58 +32,57 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin):
}
}
def print_labels(self, label: LabelTemplate, items: list, request, **kwargs):
"""Handle printing of multiple labels.
- Label outputs are concatenated together, and we return a single PDF file.
- If DEBUG mode is enabled, we return a single HTML file.
"""
debug = self.get_setting('DEBUG')
# Keep track of individual label outputs
# These will be stitched together at the end of printing
outputs = []
output_file = None
debug = None
for item in items:
label.object_to_print = item
def before_printing(self):
"""Reset the list of label outputs."""
self.outputs = []
self.debug = None
outputs.append(self.print_label(label, request, debug=debug, **kwargs))
def in_debug_mode(self):
"""Check if the plugin is printing in debug mode."""
if self.debug is None:
self.debug = str2bool(self.get_setting('DEBUG'))
if self.get_setting('DEBUG'):
html = '\n'.join(outputs)
return self.debug
output_file = ContentFile(html, 'labels.html')
def print_label(self, **kwargs):
"""Print a single label."""
label = kwargs['label_instance']
instance = kwargs['item_instance']
if self.in_debug_mode():
# In debug mode, return raw HTML output
output = self.render_to_html(label, instance, None, **kwargs)
else:
# Output is already provided
output = kwargs['pdf_file']
self.outputs.append(output)
def get_generated_file(self, **kwargs):
"""Return the generated file, by stitching together the individual label outputs."""
if len(self.outputs) == 0:
return None
if self.in_debug_mode():
# Simple HTML output
data = '\n'.join(self.outputs)
filename = 'labels.html'
else:
# Stitch together the PDF outputs
pages = []
# Following process is required to stitch labels together into a single PDF
for output in outputs:
for output in self.outputs:
doc = output.get_document()
for page in doc.pages:
pages.append(page)
pdf = outputs[0].get_document().copy(pages).write_pdf()
data = self.outputs[0].get_document().copy(pages).write_pdf()
filename = kwargs.get('filename', 'labels.pdf')
# Create label output file
output_file = ContentFile(pdf, 'labels.pdf')
# Save the generated file to the database
output = LabelOutput.objects.create(label=output_file, user=request.user)
return JsonResponse({
'file': output.label.url,
'success': True,
'message': f'{len(items)} labels generated',
})
def print_label(self, label: LabelTemplate, request, **kwargs):
"""Handle printing of a single label.
Returns either a PDF or HTML output, depending on the DEBUG setting.
"""
debug = kwargs.get('debug', self.get_setting('DEBUG'))
if debug:
return self.render_to_html(label, request, **kwargs)
return self.render_to_pdf(label, request, **kwargs)
return ContentFile(data, name=filename)

View File

@ -10,11 +10,11 @@ from rest_framework import serializers
from common.models import InvenTreeUserSetting
from InvenTree.serializers import DependentField
from InvenTree.tasks import offload_task
from label.models import LabelTemplate
from machine.machine_types import LabelPrinterBaseDriver, LabelPrinterMachine
from plugin import InvenTreePlugin
from plugin.machine import registry
from plugin.mixins import LabelPrintingMixin
from report.models import LabelTemplate
def get_machine_and_driver(machine_pk: str):
@ -63,7 +63,7 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin):
VERSION = '1.0.0'
AUTHOR = _('InvenTree contributors')
def print_labels(self, label: LabelTemplate, items, request, **kwargs):
def print_labels(self, label: LabelTemplate, output, items, request, **kwargs):
"""Print labels implementation that calls the correct machine driver print_labels method."""
machine, driver = get_machine_and_driver(
kwargs['printing_options'].get('machine', '')
@ -111,9 +111,10 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin):
"""Custom __init__ method to dynamically override the machine choices based on the request."""
super().__init__(*args, **kwargs)
view = kwargs['context']['view']
template = view.get_object()
items_to_print = view.get_items()
# TODO @matmair Re-enable this when the need is clear
# view = kwargs['context']['view']
template = None # view.get_object()
items_to_print = None # view.get_items()
# get all available printers for each driver
machines: list[LabelPrinterMachine] = []

View File

@ -12,9 +12,9 @@ import weasyprint
from rest_framework import serializers
import report.helpers
from label.models import LabelOutput, LabelTemplate
from plugin import InvenTreePlugin
from plugin.mixins import LabelPrintingMixin, SettingsMixin
from report.models import LabelOutput, LabelTemplate
logger = logging.getLogger('inventree')
@ -68,8 +68,13 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
PrintingOptionsSerializer = LabelPrintingOptionsSerializer
def print_labels(self, label: LabelTemplate, items: list, request, **kwargs):
"""Handle printing of the provided labels."""
def print_labels(
self, label: LabelTemplate, output: LabelOutput, items: list, request, **kwargs
):
"""Handle printing of the provided labels.
Note that we override the entire print_labels method for this plugin.
"""
printing_options = kwargs['printing_options']
# Extract page size for the label sheet
@ -134,15 +139,10 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
html = weasyprint.HTML(string=html_data)
document = html.render().write_pdf()
output_file = ContentFile(document, 'labels.pdf')
output = LabelOutput.objects.create(label=output_file, user=request.user)
return JsonResponse({
'file': output.label.url,
'success': True,
'message': f'{len(items)} labels generated',
})
output.output = ContentFile(document, 'labels.pdf')
output.progress = 100
output.complete = True
output.save()
def print_page(self, label: LabelTemplate, items: list, request, **kwargs):
"""Generate a single page of labels.
@ -185,7 +185,7 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
# Render the individual label template
# Note that we disable @page styling for this
cell = label.render_as_string(
request, target_object=items[idx], insert_page_style=False
items[idx], request, insert_page_style=False
)
html += cell
except Exception as exc:

View File

@ -7,7 +7,7 @@ from django.conf import settings
from django.contrib import admin
from django.contrib.auth.models import User
from django.db import models
from django.db.utils import IntegrityError
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import common.models
@ -24,6 +24,11 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
active: Should the plugin be loaded?
"""
@staticmethod
def get_api_url():
"""Return the API URL associated with the PluginConfig model."""
return reverse('api-plugin-list')
class Meta:
"""Meta for PluginConfig."""

View File

@ -34,7 +34,9 @@ class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
print(f"Printing Label: {kwargs['filename']} (User: {kwargs['user']})")
pdf_data = kwargs['pdf_data']
png_file = self.render_to_png(label=None, pdf_data=pdf_data)
png_file = self.render_to_png(
kwargs['label_instance'], kwargs['item_instance'], **kwargs
)
filename = str(BASE_DIR / '_testfolder' / 'label.pdf')

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