mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 03:00: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
@ -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
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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:
|
||||
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
|
||||
|
@ -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,
|
||||
|
@ -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."""
|
||||
|
||||
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)
|
||||
|
Reference in New Issue
Block a user