mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +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
						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