2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-01 11:10:54 +00:00

Report merge (#9532)

* Report merge

* Remove auto-generated file

* Remove pre-commit file

* Revert "Remove pre-commit file"

This reverts commit 00d80bae2a.

* Update API version

* Reduced duplicated logic

* reset pre-commit config

* Added migration files

* Added unit test

* Removed redundant migration

* Updated migration file

* Added a default report template with merge enabled

* Unit test to ensure a single page is generated

* Added docs to support merge feature

* Clean up

* Clean up

* Fixed unresolved link

* Updated API version

* Fixed test report path issue

* Add plugin context for each instance

* merge in master

* Fixed formating

* Added more detailed user guide

* Updated docs

* Added assert to ensure test html output exists

* Updated docs

* Fixed report test path

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Tuan (Tristan) Le
2025-06-20 00:06:28 -04:00
committed by GitHub
parent 45daef8442
commit 786b52d016
12 changed files with 337 additions and 37 deletions

View File

@ -7,7 +7,7 @@ The API schema as documented below is generated using the [drf-spectactular](htt
## API Version
This documentation is for API version: `339`
This documentation is for API version: `352`
!!! tip "API Schema History"
We track API schema changes, and provide a snapshot of each API schema version in the [API schema repository](https://github.com/inventree/schema/).

View File

@ -23,6 +23,28 @@ In addition to the [global context](#global-context), all *report* templates hav
{{ report_context("base", "report") }}
When using the `merge` context variable, the selected items are available in the `instances` list. {{ templatefile("report/inventree_stock_report_merge.html") }} shows a complete example. To access individual item attributes, you can either loop through the `instances` or access them by index like `instance.0.name`.
Below is an example template that generates a single report for some selected parts. Each part occupies a row in the table
```html
{% raw %}
<h2>Merged Report for Selected Parts</h2>
<table>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
{% for part in instances %}
<tr>
<td>{{ part.name }}</td>
<td>{{ part.description }}</td>
</tr>
{% endfor %}
</table>
{% endraw %}
```
Note that custom plugins may also add additional context variables to the report context.
::: report.models.ReportTemplate.get_context

View File

@ -44,3 +44,5 @@ For example, rendering the name of a part (which is available in the particular
</p></i>
{% endraw %}
```
#### Rendering a single report vs. multiple report from selection
Users can select multiple items such as `part`, `stockItem`,...etc to render from a report template. By default, the `merge` attribute of report template is disabled, which means an independent report will be generated for each item in the list of selected items. If `merge` is enabled, all selected items will be available in the `instances` context variable of the report template. Users are free to access them by indexing or in a loop. For more details, visit [context variable](./context_variables.md)

View File

@ -22,6 +22,8 @@ The following report templates are provided "out of the box" and can be used as
| [Sales Order Shipment](#sales-order-shipment) | [SalesOrderShipment](../sales/sales_order.md) | Sales Order Shipment report |
| [Stock Location](#stock-location) | [StockLocation](../stock/index.md#stock-location) | Stock Location report |
| [Test Report](#test-report) | [StockItem](../stock/index.md#stock-item) | Test Report |
| [Selected Stock Items Report](#selected-stock-items-report) | [StockItem](../stock/index.md#stock-item) | Selected Stock Items report |
### Bill of Materials Report
@ -55,6 +57,10 @@ The following report templates are provided "out of the box" and can be used as
{{ templatefile("report/inventree_test_report.html") }}
### Selected Stock Items Report
{{ templatefile("report/inventree_stock_report_merge.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:

View File

@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 353
INVENTREE_API_VERSION = 354
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v354 -> 2025-06-09 : https://github.com/inventree/InvenTree/pull/9532
- Adds "merge" field to the ReportTemplate model
v353 -> 2025-06-19 : https://github.com/inventree/InvenTree/pull/9608
- Adds email endpoints

View File

@ -224,6 +224,13 @@ class ReportConfig(AppConfig):
'description': 'Sample stock item test report',
'model_type': 'stockitem',
},
{
'file': 'inventree_stock_report_merge.html',
'name': 'InvenTree Default Stock Report Merge',
'description': 'Sample stock item report merge',
'model_type': 'stockitem',
'merge': True,
},
{
'file': 'inventree_stock_location_report.html',
'name': 'InvenTree Stock Location Report',

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.20 on 2025-04-03 01:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("report", "0030_alter_labeltemplate_model_type_and_more"),
]
operations = [
migrations.AddField(
model_name="reporttemplate",
name="merge",
field=models.BooleanField(
default=False,
help_text="Render a single report against selected items",
verbose_name="Merge",
),
),
]

View File

@ -170,10 +170,12 @@ class ReportContextExtension(TypedDict):
Attributes:
page_size: The page size of the report
landscape: Boolean value, True if the report is in landscape mode
merge: Boolean value, True if the a single report is generated against multiple items
"""
page_size: str
landscape: bool
merge: bool
class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
@ -231,31 +233,34 @@ class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
return template_string.render(Context(context))
def render_as_string(self, instance, request=None, **kwargs) -> str:
def render_as_string(self, instance, request=None, context=None, **kwargs) -> str:
"""Render the report to a HTML string.
Arguments:
instance: The model instance to render against
request: A HTTPRequest object (optional)
context: Django template language contexts (optional)
Returns:
str: HTML string
"""
context = self.get_context(instance, request, **kwargs)
if context is None:
context = self.get_context(instance, request, **kwargs)
return render_to_string(self.template_name, context, request)
def render(self, instance, request=None, **kwargs) -> bytes:
def render(self, instance, request=None, context=None, **kwargs) -> bytes:
"""Render the template to a PDF file.
Arguments:
instance: The model instance to render against
request: A HTTPRequest object (optional)
context: Django template langaguage contexts (optional)
Returns:
bytes: PDF data
"""
html = self.render_as_string(instance, request, **kwargs)
html = self.render_as_string(instance, request, context, **kwargs)
pdf = HTML(string=html).write_pdf()
return pdf
@ -372,6 +377,12 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
help_text=_('Render report in landscape orientation'),
)
merge = models.BooleanField(
default=False,
verbose_name=_('Merge'),
help_text=_('Render a single report against selected items'),
)
def get_report_size(self) -> str:
"""Return the printable page size for this report."""
try:
@ -388,17 +399,29 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
return page_size
def get_context(self, instance, request=None, **kwargs):
"""Supply context data to the report template for rendering."""
base_context = super().get_context(instance, request)
def get_report_context(self):
"""Return report template context."""
report_context: ReportContextExtension = {
'page_size': self.get_report_size(),
'landscape': self.landscape,
'merge': self.merge,
}
return report_context
def get_context(self, instance, request=None, **kwargs):
"""Supply context data to the report template for rendering."""
base_context = super().get_context(instance, request)
report_context: ReportContextExtension = self.get_report_context()
context = {**base_context, **report_context}
# Pass the context through to the plugin registry for any additional information
context = self.get_plugin_context(instance, request, context)
return context
def get_plugin_context(self, instance, request, context):
"""Get the context for the plugin."""
for plugin in registry.with_mixin(PluginMixinEnum.REPORT):
try:
plugin.add_report_context(self, instance, request, context)
@ -407,6 +430,27 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
return context
def handle_attachment(self, instance, report, report_name, request, debug_mode):
"""Attach the generated report to the model instance (if required)."""
if self.attach_to_model and not debug_mode:
instance.create_attachment(
attachment=ContentFile(report, report_name),
comment=_(f'Report generated from template {self.name}'),
upload_user=request.user
if request and request.user.is_authenticated
else None,
)
def notify_plugins(self, instance, report, request):
"""Provide generated report to any interested plugins."""
report_plugins = registry.with_mixin(PluginMixinEnum.REPORT)
for plugin in report_plugins:
try:
plugin.report_callback(self, instance, report, request)
except Exception:
InvenTree.exceptions.log_error('report_callback', plugin=plugin.slug)
def print(self, items: list, request=None, output=None, **kwargs) -> DataOutput:
"""Print reports for a list of items against this template.
@ -438,8 +482,6 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
# Start with a default report name
report_name = None
report_plugins = registry.with_mixin(PluginMixinEnum.REPORT)
# If a DataOutput object is not provided, create a new one
if not output:
output = DataOutput.objects.create(
@ -459,46 +501,71 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
output.save()
try:
for instance in items:
context = self.get_context(instance, request)
if self.merge:
base_context = super().base_context(request)
report_context = self.get_report_context()
item_contexts = []
for instance in items:
instance_context = instance.report_context()
instance_context = self.get_plugin_context(
instance, request, instance_context
)
item_contexts.append(instance_context)
contexts = {
**base_context,
**report_context,
'instances': item_contexts,
}
if report_name is None:
report_name = self.generate_filename(context)
report_name = self.generate_filename(contexts)
# Render the report output
try:
if debug_mode:
report = self.render_as_string(instance, request)
report = self.render_as_string(instance, request, contexts)
else:
report = self.render(instance, request)
report = self.render(instance, request, contexts)
except TemplateDoesNotExist as e:
t_name = str(e) or self.template
raise ValidationError(f'Template file {t_name} does not exist')
outputs.append(report)
# Attach the generated report to the model instance (if required)
if self.attach_to_model and not debug_mode:
instance.create_attachment(
attachment=ContentFile(report, report_name),
comment=_(f'Report generated from template {self.name}'),
upload_user=request.user
if request and request.user.is_authenticated
else None,
)
# Provide generated report to any interested plugins
for plugin in report_plugins:
try:
plugin.report_callback(self, instance, report, request)
except Exception:
InvenTree.exceptions.log_error(
'report_callback', plugin=plugin.slug
)
self.handle_attachment(
instance, report, report_name, request, debug_mode
)
self.notify_plugins(instance, report, request)
# Update the progress of the report generation
output.progress += 1
output.save()
else:
for instance in items:
context = self.get_context(instance, request)
if report_name is None:
report_name = self.generate_filename(context)
# Render the report output
try:
if debug_mode:
report = self.render_as_string(instance, request, None)
else:
report = self.render(instance, request, None)
except TemplateDoesNotExist as e:
t_name = str(e) or self.template
raise ValidationError(f'Template file {t_name} does not exist')
outputs.append(report)
self.handle_attachment(
instance, report, report_name, request, debug_mode
)
self.notify_plugins(instance, report, request)
# Update the progress of the report generation
output.progress += 1
output.save()
except Exception as exc:
# Something went wrong during the report generation process

View File

@ -65,7 +65,12 @@ class ReportTemplateSerializer(ReportSerializerBase):
"""Metaclass options."""
model = report.models.ReportTemplate
fields = [*ReportSerializerBase.base_fields(), 'page_size', 'landscape']
fields = [
*ReportSerializerBase.base_fields(),
'page_size',
'landscape',
'merge',
]
page_size = serializers.ChoiceField(
required=False,

View File

@ -0,0 +1,127 @@
{% extends "report/inventree_report_base.html" %}
{% load i18n %}
{% load report %}
{% load inventree_extras %}
{% block style %}
.test-table {
width: 100%;
}
{% block bottom_left %}
content: "{% format_date date %}";
{% endblock bottom_left %}
{% block bottom_center %}
content: "{% inventree_version shortstring=True %}";
{% endblock bottom_center %}
{% block top_center %}
content: "{% trans 'Stock Item Test Report' %}";
{% endblock top_center %}
.test-row {
padding: 3px;
}
.test-pass {
color: #5f5;
}
.test-fail {
color: #F55;
}
.test-not-found {
color: #33A;
}
.required-test-not-found {
color: #EEE;
background-color: #F55;
}
.container {
padding: 5px;
border: 1px solid;
}
.text-left {
display: inline-block;
width: 50%;
}
.img-right {
display: inline;
align-content: right;
align-items: right;
width: 50%;
}
.part-img {
height: 4cm;
}
{% endblock style %}
{% block pre_page_content %}
{% endblock pre_page_content %}
{% block page_content %}
{% for item in instances %}
<div class='container'>
<div class='text-left'>
<h2>
{{ item.part.full_name }}
</h2>
<p>{{ item.part.description }}</p>
<p><em>{{ item.stock_item.location }}</em></p>
<p><em>Stock Item ID: {{ item.stock_item.pk }}</em></p>
</div>
<div class='img-right'>
<img class='part-img' alt='{% trans "Part image" %}' src="{% part_image item.part height=480 %}">
<hr>
<h4>
{% if item.stock_item.is_serialized %}
{% trans "Serial Number" %}: {{ item.stock_item.serial }}
{% else %}
{% trans "Quantity" %}: {% decimal item.stock_item.quantity %}
{% endif %}
</h4>
</div>
</div>
{% if item.installed_items|length > 0 %}
<h3>{% trans "Installed Items" %}</h3>
<table class='table test-table'>
<thead>
</thead>
<tbody>
{% for sub_item in item.installed_items %}
<tr>
<td>
<img src='{% part_image sub_item.part height=240 %}' class='part-img' alt='{% trans "Part image" %}' style='max-width: 24px; max-height: 24px;'>
{{ sub_item.part.full_name }}
</td>
<td>
{% if sub_item.serialized %}
{% trans "Serial" %}: {{ sub_item.serial }}
{% else %}
{% trans "Quantity" %}: {% decimal sub_item.quantity %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endfor %}
{% endblock page_content %}
{% block post_page_content %}
{% endblock post_page_content %}

View File

@ -1,14 +1,17 @@
"""Unit testing for the various report models."""
import os
from io import StringIO
from django.apps import apps
from django.conf import settings
from django.core.cache import cache
from django.urls import reverse
import report.models as report_models
from build.models import Build
from common.models import Attachment
from common.settings import set_global_setting
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase
from order.models import ReturnOrder, SalesOrder
from part.models import Part
@ -486,6 +489,36 @@ class TestReportTest(PrintTestMixins, ReportTest):
# The attachment should be a PDF
self.assertTrue(attachment.attachment.name.endswith('.pdf'))
# Set DEBUG_MODE to return the report as an HTML file
set_global_setting('REPORT_DEBUG_MODE', True)
# Grab the report template
template_merge = ReportTemplate.objects.filter(
enabled=True, model_type='stockitem', merge=True
).first()
# Grab the first 3 stock items
items = StockItem.objects.all()[:3]
response = self.post(
url,
{'template': template_merge.pk, 'items': [item.pk for item in items]},
expected_code=201,
)
# Open and read the output HTML as a string
html_report = ''
report_path = os.path.join(
settings.MEDIA_ROOT, response.data['output'].replace('/media/', '', 1)
)
self.assertTrue(response.data['output'])
with open(report_path, encoding='utf-8') as f:
html_report = f.read()
# Assuming the number of <head> and <body> correlates to the number of pages
# in the generated PDF
self.assertEqual(html_report.count('<head>'), 1)
self.assertEqual(html_report.count('<body>'), 1)
def test_mdl_build(self):
"""Test the Build model."""
self.run_print_test(Build, 'build', label=False)

View File

@ -21,6 +21,12 @@ function ReportTemplateTable() {
<YesNoButton value={instance.landscape} />
)
},
merge: {
label: t`Merge`,
modelRenderer: (instance: any) => (
<YesNoButton value={instance.merge} />
)
},
attach_to_model: {
label: t`Attach to Model`,
modelRenderer: (instance: any) => (