From 786b52d016837e9c1b1171b731e0c9f369227a72 Mon Sep 17 00:00:00 2001 From: "Tuan (Tristan) Le" <38996499+tristanle22@users.noreply.github.com> Date: Fri, 20 Jun 2025 00:06:28 -0400 Subject: [PATCH] Report merge (#9532) * Report merge * Remove auto-generated file * Remove pre-commit file * Revert "Remove pre-commit file" This reverts commit 00d80bae2a551d103473bb932efa03cff18b5e9f. * 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 --- docs/docs/api/schema.md | 2 +- docs/docs/report/context_variables.md | 22 +++ docs/docs/report/report.md | 2 + docs/docs/report/samples.md | 6 + .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/report/apps.py | 7 + .../migrations/0031_reporttemplate_merge.py | 22 +++ src/backend/InvenTree/report/models.py | 135 +++++++++++++----- src/backend/InvenTree/report/serializers.py | 7 +- .../report/inventree_stock_report_merge.html | 127 ++++++++++++++++ src/backend/InvenTree/report/tests.py | 33 +++++ .../AdminCenter/ReportTemplatePanel.tsx | 6 + 12 files changed, 337 insertions(+), 37 deletions(-) create mode 100644 src/backend/InvenTree/report/migrations/0031_reporttemplate_merge.py create mode 100644 src/backend/InvenTree/report/templates/report/inventree_stock_report_merge.html diff --git a/docs/docs/api/schema.md b/docs/docs/api/schema.md index 5d4ac82a23..f4ce2837f3 100644 --- a/docs/docs/api/schema.md +++ b/docs/docs/api/schema.md @@ -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/). diff --git a/docs/docs/report/context_variables.md b/docs/docs/report/context_variables.md index 670a26fea5..ddd8a8d8d0 100644 --- a/docs/docs/report/context_variables.md +++ b/docs/docs/report/context_variables.md @@ -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 %} +

Merged Report for Selected Parts

+ + + + + + {% for part in instances %} + + + + + {% endfor %} +
NameDescription
{{ part.name }}{{ part.description }}
+{% endraw %} +``` + Note that custom plugins may also add additional context variables to the report context. ::: report.models.ReportTemplate.get_context diff --git a/docs/docs/report/report.md b/docs/docs/report/report.md index cff02f5141..795c0f9483 100644 --- a/docs/docs/report/report.md +++ b/docs/docs/report/report.md @@ -44,3 +44,5 @@ For example, rendering the name of a part (which is available in the particular

{% 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) diff --git a/docs/docs/report/samples.md b/docs/docs/report/samples.md index 0c0e2fecaf..0489e3b92c 100644 --- a/docs/docs/report/samples.md +++ b/docs/docs/report/samples.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 | | [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: diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index a74f297afd..3ea7e8c2d1 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/report/apps.py b/src/backend/InvenTree/report/apps.py index cf7d679ffd..eafa43c46a 100644 --- a/src/backend/InvenTree/report/apps.py +++ b/src/backend/InvenTree/report/apps.py @@ -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', diff --git a/src/backend/InvenTree/report/migrations/0031_reporttemplate_merge.py b/src/backend/InvenTree/report/migrations/0031_reporttemplate_merge.py new file mode 100644 index 0000000000..184abe45c8 --- /dev/null +++ b/src/backend/InvenTree/report/migrations/0031_reporttemplate_merge.py @@ -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", + ), + ), + ] diff --git a/src/backend/InvenTree/report/models.py b/src/backend/InvenTree/report/models.py index 42c0273e3e..d1c290b7f8 100644 --- a/src/backend/InvenTree/report/models.py +++ b/src/backend/InvenTree/report/models.py @@ -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 diff --git a/src/backend/InvenTree/report/serializers.py b/src/backend/InvenTree/report/serializers.py index fe542742ff..0f133898f6 100644 --- a/src/backend/InvenTree/report/serializers.py +++ b/src/backend/InvenTree/report/serializers.py @@ -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, diff --git a/src/backend/InvenTree/report/templates/report/inventree_stock_report_merge.html b/src/backend/InvenTree/report/templates/report/inventree_stock_report_merge.html new file mode 100644 index 0000000000..3f97342ebe --- /dev/null +++ b/src/backend/InvenTree/report/templates/report/inventree_stock_report_merge.html @@ -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 %} +
+
+

+ {{ item.part.full_name }} +

+

{{ item.part.description }}

+

{{ item.stock_item.location }}

+

Stock Item ID: {{ item.stock_item.pk }}

+
+
+ {% trans "Part image" %} +
+

+ {% if item.stock_item.is_serialized %} + {% trans "Serial Number" %}: {{ item.stock_item.serial }} + {% else %} + {% trans "Quantity" %}: {% decimal item.stock_item.quantity %} + {% endif %} +

+
+
+ +{% if item.installed_items|length > 0 %} +

{% trans "Installed Items" %}

+ + + + + + {% for sub_item in item.installed_items %} + + + + + {% endfor %} + +
+ {% trans "Part image" %} + {{ sub_item.part.full_name }} + + {% if sub_item.serialized %} + {% trans "Serial" %}: {{ sub_item.serial }} + {% else %} + {% trans "Quantity" %}: {% decimal sub_item.quantity %} + {% endif %} +
+ +{% endif %} +{% endfor %} +{% endblock page_content %} + +{% block post_page_content %} + +{% endblock post_page_content %} diff --git a/src/backend/InvenTree/report/tests.py b/src/backend/InvenTree/report/tests.py index 05333266ba..e351084e11 100644 --- a/src/backend/InvenTree/report/tests.py +++ b/src/backend/InvenTree/report/tests.py @@ -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 and correlates to the number of pages + # in the generated PDF + self.assertEqual(html_report.count(''), 1) + self.assertEqual(html_report.count(''), 1) + def test_mdl_build(self): """Test the Build model.""" self.run_print_test(Build, 'build', label=False) diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/ReportTemplatePanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/ReportTemplatePanel.tsx index 5ebdb54fc4..6daf4f0b83 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/ReportTemplatePanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/ReportTemplatePanel.tsx @@ -21,6 +21,12 @@ function ReportTemplateTable() { ) }, + merge: { + label: t`Merge`, + modelRenderer: (instance: any) => ( + + ) + }, attach_to_model: { label: t`Attach to Model`, modelRenderer: (instance: any) => (