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:
committed by
GitHub
parent
45daef8442
commit
786b52d016
@ -7,7 +7,7 @@ The API schema as documented below is generated using the [drf-spectactular](htt
|
|||||||
|
|
||||||
## API Version
|
## API Version
|
||||||
|
|
||||||
This documentation is for API version: `339`
|
This documentation is for API version: `352`
|
||||||
|
|
||||||
!!! tip "API Schema History"
|
!!! tip "API Schema History"
|
||||||
We track API schema changes, and provide a snapshot of each API schema version in the [API schema repository](https://github.com/inventree/schema/).
|
We track API schema changes, and provide a snapshot of each API schema version in the [API schema repository](https://github.com/inventree/schema/).
|
||||||
|
@ -23,6 +23,28 @@ In addition to the [global context](#global-context), all *report* templates hav
|
|||||||
|
|
||||||
{{ report_context("base", "report") }}
|
{{ 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.
|
Note that custom plugins may also add additional context variables to the report context.
|
||||||
|
|
||||||
::: report.models.ReportTemplate.get_context
|
::: report.models.ReportTemplate.get_context
|
||||||
|
@ -44,3 +44,5 @@ For example, rendering the name of a part (which is available in the particular
|
|||||||
</p></i>
|
</p></i>
|
||||||
{% endraw %}
|
{% 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)
|
||||||
|
@ -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 |
|
| [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 |
|
| [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 |
|
| [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
|
### 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") }}
|
{{ templatefile("report/inventree_test_report.html") }}
|
||||||
|
|
||||||
|
### Selected Stock Items Report
|
||||||
|
|
||||||
|
{{ templatefile("report/inventree_stock_report_merge.html") }}
|
||||||
|
|
||||||
## Label Templates
|
## 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:
|
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:
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
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
|
v353 -> 2025-06-19 : https://github.com/inventree/InvenTree/pull/9608
|
||||||
- Adds email endpoints
|
- Adds email endpoints
|
||||||
|
|
||||||
|
@ -224,6 +224,13 @@ class ReportConfig(AppConfig):
|
|||||||
'description': 'Sample stock item test report',
|
'description': 'Sample stock item test report',
|
||||||
'model_type': 'stockitem',
|
'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',
|
'file': 'inventree_stock_location_report.html',
|
||||||
'name': 'InvenTree Stock Location Report',
|
'name': 'InvenTree Stock Location Report',
|
||||||
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -170,10 +170,12 @@ class ReportContextExtension(TypedDict):
|
|||||||
Attributes:
|
Attributes:
|
||||||
page_size: The page size of the report
|
page_size: The page size of the report
|
||||||
landscape: Boolean value, True if the report is in landscape mode
|
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
|
page_size: str
|
||||||
landscape: bool
|
landscape: bool
|
||||||
|
merge: bool
|
||||||
|
|
||||||
|
|
||||||
class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
|
class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||||
@ -231,31 +233,34 @@ class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
|
|||||||
|
|
||||||
return template_string.render(Context(context))
|
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.
|
"""Render the report to a HTML string.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
instance: The model instance to render against
|
instance: The model instance to render against
|
||||||
request: A HTTPRequest object (optional)
|
request: A HTTPRequest object (optional)
|
||||||
|
context: Django template language contexts (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: HTML string
|
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)
|
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.
|
"""Render the template to a PDF file.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
instance: The model instance to render against
|
instance: The model instance to render against
|
||||||
request: A HTTPRequest object (optional)
|
request: A HTTPRequest object (optional)
|
||||||
|
context: Django template langaguage contexts (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bytes: PDF data
|
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()
|
pdf = HTML(string=html).write_pdf()
|
||||||
|
|
||||||
return pdf
|
return pdf
|
||||||
@ -372,6 +377,12 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
|||||||
help_text=_('Render report in landscape orientation'),
|
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:
|
def get_report_size(self) -> str:
|
||||||
"""Return the printable page size for this report."""
|
"""Return the printable page size for this report."""
|
||||||
try:
|
try:
|
||||||
@ -388,17 +399,29 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
|||||||
|
|
||||||
return page_size
|
return page_size
|
||||||
|
|
||||||
def get_context(self, instance, request=None, **kwargs):
|
def get_report_context(self):
|
||||||
"""Supply context data to the report template for rendering."""
|
"""Return report template context."""
|
||||||
base_context = super().get_context(instance, request)
|
|
||||||
report_context: ReportContextExtension = {
|
report_context: ReportContextExtension = {
|
||||||
'page_size': self.get_report_size(),
|
'page_size': self.get_report_size(),
|
||||||
'landscape': self.landscape,
|
'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}
|
context = {**base_context, **report_context}
|
||||||
|
|
||||||
# Pass the context through to the plugin registry for any additional information
|
# 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):
|
for plugin in registry.with_mixin(PluginMixinEnum.REPORT):
|
||||||
try:
|
try:
|
||||||
plugin.add_report_context(self, instance, request, context)
|
plugin.add_report_context(self, instance, request, context)
|
||||||
@ -407,6 +430,27 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
|||||||
|
|
||||||
return context
|
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:
|
def print(self, items: list, request=None, output=None, **kwargs) -> DataOutput:
|
||||||
"""Print reports for a list of items against this template.
|
"""Print reports for a list of items against this template.
|
||||||
|
|
||||||
@ -438,8 +482,6 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
|||||||
# Start with a default report name
|
# Start with a default report name
|
||||||
report_name = None
|
report_name = None
|
||||||
|
|
||||||
report_plugins = registry.with_mixin(PluginMixinEnum.REPORT)
|
|
||||||
|
|
||||||
# If a DataOutput object is not provided, create a new one
|
# If a DataOutput object is not provided, create a new one
|
||||||
if not output:
|
if not output:
|
||||||
output = DataOutput.objects.create(
|
output = DataOutput.objects.create(
|
||||||
@ -459,46 +501,71 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
|||||||
output.save()
|
output.save()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for instance in items:
|
if self.merge:
|
||||||
context = self.get_context(instance, request)
|
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:
|
if report_name is None:
|
||||||
report_name = self.generate_filename(context)
|
report_name = self.generate_filename(contexts)
|
||||||
|
|
||||||
# Render the report output
|
|
||||||
try:
|
try:
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
report = self.render_as_string(instance, request)
|
report = self.render_as_string(instance, request, contexts)
|
||||||
else:
|
else:
|
||||||
report = self.render(instance, request)
|
report = self.render(instance, request, contexts)
|
||||||
except TemplateDoesNotExist as e:
|
except TemplateDoesNotExist as e:
|
||||||
t_name = str(e) or self.template
|
t_name = str(e) or self.template
|
||||||
raise ValidationError(f'Template file {t_name} does not exist')
|
raise ValidationError(f'Template file {t_name} does not exist')
|
||||||
|
|
||||||
outputs.append(report)
|
outputs.append(report)
|
||||||
|
self.handle_attachment(
|
||||||
# Attach the generated report to the model instance (if required)
|
instance, report, report_name, request, debug_mode
|
||||||
if self.attach_to_model and not debug_mode:
|
)
|
||||||
instance.create_attachment(
|
self.notify_plugins(instance, report, request)
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update the progress of the report generation
|
# Update the progress of the report generation
|
||||||
output.progress += 1
|
output.progress += 1
|
||||||
output.save()
|
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:
|
except Exception as exc:
|
||||||
# Something went wrong during the report generation process
|
# Something went wrong during the report generation process
|
||||||
|
@ -65,7 +65,12 @@ class ReportTemplateSerializer(ReportSerializerBase):
|
|||||||
"""Metaclass options."""
|
"""Metaclass options."""
|
||||||
|
|
||||||
model = report.models.ReportTemplate
|
model = report.models.ReportTemplate
|
||||||
fields = [*ReportSerializerBase.base_fields(), 'page_size', 'landscape']
|
fields = [
|
||||||
|
*ReportSerializerBase.base_fields(),
|
||||||
|
'page_size',
|
||||||
|
'landscape',
|
||||||
|
'merge',
|
||||||
|
]
|
||||||
|
|
||||||
page_size = serializers.ChoiceField(
|
page_size = serializers.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -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 %}
|
@ -1,14 +1,17 @@
|
|||||||
"""Unit testing for the various report models."""
|
"""Unit testing for the various report models."""
|
||||||
|
|
||||||
|
import os
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
import report.models as report_models
|
import report.models as report_models
|
||||||
from build.models import Build
|
from build.models import Build
|
||||||
from common.models import Attachment
|
from common.models import Attachment
|
||||||
|
from common.settings import set_global_setting
|
||||||
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase
|
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase
|
||||||
from order.models import ReturnOrder, SalesOrder
|
from order.models import ReturnOrder, SalesOrder
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
@ -486,6 +489,36 @@ class TestReportTest(PrintTestMixins, ReportTest):
|
|||||||
# The attachment should be a PDF
|
# The attachment should be a PDF
|
||||||
self.assertTrue(attachment.attachment.name.endswith('.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):
|
def test_mdl_build(self):
|
||||||
"""Test the Build model."""
|
"""Test the Build model."""
|
||||||
self.run_print_test(Build, 'build', label=False)
|
self.run_print_test(Build, 'build', label=False)
|
||||||
|
@ -21,6 +21,12 @@ function ReportTemplateTable() {
|
|||||||
<YesNoButton value={instance.landscape} />
|
<YesNoButton value={instance.landscape} />
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
merge: {
|
||||||
|
label: t`Merge`,
|
||||||
|
modelRenderer: (instance: any) => (
|
||||||
|
<YesNoButton value={instance.merge} />
|
||||||
|
)
|
||||||
|
},
|
||||||
attach_to_model: {
|
attach_to_model: {
|
||||||
label: t`Attach to Model`,
|
label: t`Attach to Model`,
|
||||||
modelRenderer: (instance: any) => (
|
modelRenderer: (instance: any) => (
|
||||||
|
Reference in New Issue
Block a user