mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 21:25:42 +00:00 
			
		
		
		
	[WIP] Background reports (#9199)
* Update report generation progress * Add shim task for offloading report printing * Cleanup * Add detail endpoints for label and report outputs * Display report printing progress in UI * Implement similar for label printing * Reduce output for CI * Add plugin slug * Bump API version * Ensure it works with machine printing * Fix null comparison * Fix SKU link * Update playwright tests * Massively reduce log output when printing
This commit is contained in:
		
							
								
								
									
										10
									
								
								.github/workflows/qc_checks.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/qc_checks.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -273,7 +273,7 @@ jobs: | ||||
|       INVENTREE_PYTHON_TEST_PASSWORD: testpassword | ||||
|       INVENTREE_SITE_URL: http://127.0.0.1:12345 | ||||
|       INVENTREE_DEBUG: true | ||||
|       INVENTREE_LOG_LEVEL: INFO | ||||
|       INVENTREE_LOG_LEVEL: WARNING | ||||
|  | ||||
|     steps: | ||||
|       - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4.2.2 | ||||
| @@ -358,7 +358,7 @@ jobs: | ||||
|       INVENTREE_DB_HOST: "127.0.0.1" | ||||
|       INVENTREE_DB_PORT: 5432 | ||||
|       INVENTREE_DEBUG: true | ||||
|       INVENTREE_LOG_LEVEL: INFO | ||||
|       INVENTREE_LOG_LEVEL: WARNING | ||||
|       INVENTREE_CONSOLE_LOG: false | ||||
|       INVENTREE_CACHE_HOST: localhost | ||||
|       INVENTREE_PLUGINS_ENABLED: true | ||||
| @@ -408,7 +408,7 @@ jobs: | ||||
|       INVENTREE_DB_HOST: "127.0.0.1" | ||||
|       INVENTREE_DB_PORT: 3306 | ||||
|       INVENTREE_DEBUG: true | ||||
|       INVENTREE_LOG_LEVEL: INFO | ||||
|       INVENTREE_LOG_LEVEL: WARNING | ||||
|       INVENTREE_CONSOLE_LOG: false | ||||
|       INVENTREE_PLUGINS_ENABLED: true | ||||
|  | ||||
| @@ -455,7 +455,7 @@ jobs: | ||||
|       INVENTREE_DB_HOST: "127.0.0.1" | ||||
|       INVENTREE_DB_PORT: 5432 | ||||
|       INVENTREE_DEBUG: true | ||||
|       INVENTREE_LOG_LEVEL: INFO | ||||
|       INVENTREE_LOG_LEVEL: WARNING | ||||
|       INVENTREE_PLUGINS_ENABLED: false | ||||
|  | ||||
|     services: | ||||
| @@ -498,7 +498,7 @@ jobs: | ||||
|       INVENTREE_DB_ENGINE: sqlite3 | ||||
|       INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3 | ||||
|       INVENTREE_DEBUG: true | ||||
|       INVENTREE_LOG_LEVEL: INFO | ||||
|       INVENTREE_LOG_LEVEL: WARNING | ||||
|       INVENTREE_PLUGINS_ENABLED: false | ||||
|  | ||||
|     steps: | ||||
|   | ||||
| @@ -1,13 +1,17 @@ | ||||
| """InvenTree API version information.""" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 318 | ||||
| INVENTREE_API_VERSION = 319 | ||||
|  | ||||
| """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||
|  | ||||
|  | ||||
| INVENTREE_API_TEXT = """ | ||||
|  | ||||
| v319 - 2025-03-04 : https://github.com/inventree/InvenTree/pull/9199 | ||||
|     - Add detail API endpoint for the LabelOutput model | ||||
|     - Add detail API endpoint for the ReportOutput model | ||||
|  | ||||
| v318 - 2025-02-25 : https://github.com/inventree/InvenTree/pull/9116 | ||||
|     - Adds user profile API endpoints | ||||
|  | ||||
|   | ||||
| @@ -193,12 +193,12 @@ class LabelPrintingMixin: | ||||
|                 ) | ||||
|  | ||||
|             # Update the progress of the print job | ||||
|             output.progress += int(100 / N) | ||||
|             output.progress += 1 | ||||
|             output.save() | ||||
|  | ||||
|         # Mark the output as complete | ||||
|         output.complete = True | ||||
|         output.progress = 100 | ||||
|         output.progress = N | ||||
|  | ||||
|         # Add in the generated file (if applicable) | ||||
|         output.output = self.get_generated_file(**print_args) | ||||
|   | ||||
| @@ -3,7 +3,6 @@ | ||||
| from typing import cast | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.http import JsonResponse | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| from rest_framework import serializers | ||||
| @@ -79,18 +78,26 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin): | ||||
|             'printing_options': kwargs['printing_options'].get('driver_options', {}), | ||||
|         } | ||||
|  | ||||
|         # save the current used printer as last used printer | ||||
|         # only the last ten used printers are saved so that this list doesn't grow infinitely | ||||
|         last_used_printers = get_last_used_printers(request.user) | ||||
|         machine_pk = str(machine.pk) | ||||
|         if machine_pk in last_used_printers: | ||||
|             last_used_printers.remove(machine_pk) | ||||
|         last_used_printers.insert(0, machine_pk) | ||||
|         InvenTreeUserSetting.set_setting( | ||||
|             'LAST_USED_PRINTING_MACHINES', | ||||
|             ','.join(last_used_printers[:10]), | ||||
|             user=request.user, | ||||
|         ) | ||||
|         if output: | ||||
|             user = output.user | ||||
|         elif request: | ||||
|             user = request.user | ||||
|         else: | ||||
|             user = None | ||||
|  | ||||
|         # Save the current used printer as last used printer | ||||
|         # Only the last ten used printers are saved so that this list doesn't grow infinitely | ||||
|         if user and user.is_authenticated: | ||||
|             last_used_printers = get_last_used_printers(user) | ||||
|             machine_pk = str(machine.pk) | ||||
|             if machine_pk in last_used_printers: | ||||
|                 last_used_printers.remove(machine_pk) | ||||
|             last_used_printers.insert(0, machine_pk) | ||||
|             InvenTreeUserSetting.set_setting( | ||||
|                 'LAST_USED_PRINTING_MACHINES', | ||||
|                 ','.join(last_used_printers[:10]), | ||||
|                 user=user, | ||||
|             ) | ||||
|  | ||||
|         offload_task( | ||||
|             call_machine_function, | ||||
| @@ -98,15 +105,17 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin): | ||||
|             'print_labels', | ||||
|             label, | ||||
|             items, | ||||
|             output=output, | ||||
|             force_sync=settings.TESTING or driver.USE_BACKGROUND_WORKER, | ||||
|             group='plugin', | ||||
|             **print_kwargs, | ||||
|         ) | ||||
|  | ||||
|         return JsonResponse({ | ||||
|             'success': True, | ||||
|             'message': f'{len(items)} labels printed', | ||||
|         }) | ||||
|         # Inform the user that the process has been offloaded to the printer | ||||
|         if output: | ||||
|             output.output = None | ||||
|             output.complete = True | ||||
|             output.save() | ||||
|  | ||||
|     class PrintingOptionsSerializer(serializers.Serializer): | ||||
|         """Printing options serializer that adds a machine select and the machines options.""" | ||||
|   | ||||
| @@ -136,6 +136,10 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug | ||||
|  | ||||
|             idx += n_cells | ||||
|  | ||||
|             # Update printing progress | ||||
|             output.progress += 1 | ||||
|             output.save() | ||||
|  | ||||
|         if len(pages) == 0: | ||||
|             raise ValidationError(_('No labels were generated')) | ||||
|  | ||||
| @@ -152,7 +156,7 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug | ||||
|  | ||||
|             output.output = ContentFile(document, 'labels.pdf') | ||||
|  | ||||
|         output.progress = 100 | ||||
|         output.progress = n_labels | ||||
|         output.complete = True | ||||
|         output.save() | ||||
|  | ||||
|   | ||||
| @@ -194,16 +194,32 @@ class LabelPrint(GenericAPIView): | ||||
|  | ||||
|     def print(self, template, items_to_print, plugin, request): | ||||
|         """Print this label template against a number of provided items.""" | ||||
|         import report.tasks | ||||
|         from InvenTree.tasks import offload_task | ||||
|  | ||||
|         if plugin_serializer := plugin.get_printing_options_serializer( | ||||
|             request, data=request.data, context=self.get_serializer_context() | ||||
|         ): | ||||
|             plugin_serializer.is_valid(raise_exception=True) | ||||
|  | ||||
|         output = template.print( | ||||
|             items_to_print, | ||||
|             plugin, | ||||
|         # Generate a new LabelOutput object to print against | ||||
|         output = report.models.LabelOutput.objects.create( | ||||
|             template=template, | ||||
|             plugin=plugin.slug, | ||||
|             user=request.user, | ||||
|             progress=0, | ||||
|             items=len(items_to_print), | ||||
|             complete=False, | ||||
|             output=None, | ||||
|         ) | ||||
|  | ||||
|         offload_task( | ||||
|             report.tasks.print_labels, | ||||
|             template.pk, | ||||
|             [item.pk for item in items_to_print], | ||||
|             output.pk, | ||||
|             plugin.slug, | ||||
|             options=(plugin_serializer.data if plugin_serializer else {}), | ||||
|             request=request, | ||||
|         ) | ||||
|  | ||||
|         output.refresh_from_db() | ||||
| @@ -255,8 +271,30 @@ class ReportPrint(GenericAPIView): | ||||
|         return self.print(template, instances, request) | ||||
|  | ||||
|     def print(self, template, items_to_print, request): | ||||
|         """Print this report template against a number of provided items.""" | ||||
|         output = template.print(items_to_print, request) | ||||
|         """Print this report template against a number of provided items. | ||||
|  | ||||
|         This functionality is offloaded to the background worker process, | ||||
|         which will update the status of the ReportOutput object as it progresses. | ||||
|         """ | ||||
|         import report.tasks | ||||
|         from InvenTree.tasks import offload_task | ||||
|  | ||||
|         # Generate a new ReportOutput object | ||||
|         output = report.models.ReportOutput.objects.create( | ||||
|             template=template, | ||||
|             user=request.user, | ||||
|             progress=0, | ||||
|             items=len(items_to_print), | ||||
|             complete=False, | ||||
|             output=None, | ||||
|         ) | ||||
|  | ||||
|         item_ids = [item.pk for item in items_to_print] | ||||
|  | ||||
|         # Offload the task to the background worker | ||||
|         offload_task(report.tasks.print_reports, template.pk, item_ids, output.pk) | ||||
|  | ||||
|         output.refresh_from_db() | ||||
|  | ||||
|         return Response( | ||||
|             report.serializers.ReportOutputSerializer(output).data, status=201 | ||||
| @@ -317,24 +355,36 @@ class TemplateOutputMixin: | ||||
|     ordering_field_aliases = {'model_type': 'template__model_type'} | ||||
|  | ||||
|  | ||||
| class LabelOutputList( | ||||
|     TemplatePermissionMixin, TemplateOutputMixin, BulkDeleteMixin, ListAPI | ||||
| ): | ||||
|     """List endpoint for LabelOutput objects.""" | ||||
| class LabelOutputMixin(TemplatePermissionMixin, TemplateOutputMixin): | ||||
|     """Mixin class for a label output API endpoint.""" | ||||
|  | ||||
|     queryset = report.models.LabelOutput.objects.all() | ||||
|     serializer_class = report.serializers.LabelOutputSerializer | ||||
|  | ||||
|  | ||||
| class ReportOutputList( | ||||
|     TemplatePermissionMixin, TemplateOutputMixin, BulkDeleteMixin, ListAPI | ||||
| ): | ||||
|     """List endpoint for ReportOutput objects.""" | ||||
| class LabelOutputList(LabelOutputMixin, BulkDeleteMixin, ListAPI): | ||||
|     """List endpoint for LabelOutput objects.""" | ||||
|  | ||||
|  | ||||
| class LabelOutputDetail(LabelOutputMixin, RetrieveUpdateDestroyAPI): | ||||
|     """Detail endpoint for LabelOutput objects.""" | ||||
|  | ||||
|  | ||||
| class ReportOutputMixin(TemplatePermissionMixin, TemplateOutputMixin): | ||||
|     """Mixin class for a report output API endpoint.""" | ||||
|  | ||||
|     queryset = report.models.ReportOutput.objects.all() | ||||
|     serializer_class = report.serializers.ReportOutputSerializer | ||||
|  | ||||
|  | ||||
| class ReportOutputList(ReportOutputMixin, BulkDeleteMixin, ListAPI): | ||||
|     """List endpoint for ReportOutput objects.""" | ||||
|  | ||||
|  | ||||
| class ReportOutputDetail(ReportOutputMixin, RetrieveUpdateDestroyAPI): | ||||
|     """Detail endpoint for ReportOutput objects.""" | ||||
|  | ||||
|  | ||||
| label_api_urls = [ | ||||
|     # Printing endpoint | ||||
|     path('print/', LabelPrint.as_view(), name='api-label-print'), | ||||
| @@ -364,7 +414,12 @@ label_api_urls = [ | ||||
|     # Label outputs | ||||
|     path( | ||||
|         'output/', | ||||
|         include([path('', LabelOutputList.as_view(), name='api-label-output-list')]), | ||||
|         include([ | ||||
|             path( | ||||
|                 '<int:pk>/', LabelOutputDetail.as_view(), name='api-label-output-detail' | ||||
|             ), | ||||
|             path('', LabelOutputList.as_view(), name='api-label-output-list'), | ||||
|         ]), | ||||
|     ), | ||||
| ] | ||||
|  | ||||
| @@ -397,7 +452,14 @@ report_api_urls = [ | ||||
|     # Generated report outputs | ||||
|     path( | ||||
|         'output/', | ||||
|         include([path('', ReportOutputList.as_view(), name='api-report-output-list')]), | ||||
|         include([ | ||||
|             path( | ||||
|                 '<int:pk>/', | ||||
|                 ReportOutputDetail.as_view(), | ||||
|                 name='api-report-output-detail', | ||||
|             ), | ||||
|             path('', ReportOutputList.as_view(), name='api-report-output-list'), | ||||
|         ]), | ||||
|     ), | ||||
|     # Report assets | ||||
|     path( | ||||
|   | ||||
| @@ -10,12 +10,13 @@ from django.core.files.base import ContentFile | ||||
| from django.core.files.storage import default_storage | ||||
| from django.db.utils import IntegrityError, OperationalError, ProgrammingError | ||||
|  | ||||
| import structlog | ||||
| from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode | ||||
|  | ||||
| import InvenTree.exceptions | ||||
| import InvenTree.ready | ||||
|  | ||||
| logger = logging.getLogger('inventree') | ||||
| logger = structlog.getLogger('inventree') | ||||
|  | ||||
|  | ||||
| class ReportConfig(AppConfig): | ||||
| @@ -26,8 +27,12 @@ class ReportConfig(AppConfig): | ||||
|     def ready(self): | ||||
|         """This function is called whenever the app is loaded.""" | ||||
|         # Configure logging for PDF generation (disable "info" messages) | ||||
|         logging.getLogger('fontTools').setLevel(logging.WARNING) | ||||
|         logging.getLogger('weasyprint').setLevel(logging.WARNING) | ||||
|  | ||||
|         # Reduce log output for fontTools and weasyprint | ||||
|         for name, log_manager in logging.root.manager.loggerDict.items(): | ||||
|             if name.lower().startswith(('fonttools', 'weasyprint')): | ||||
|                 if hasattr(log_manager, 'setLevel'): | ||||
|                     log_manager.setLevel(logging.WARNING) | ||||
|  | ||||
|         super().ready() | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| """Report template model definitions.""" | ||||
|  | ||||
| import logging | ||||
| import os | ||||
| import sys | ||||
|  | ||||
| @@ -19,6 +18,8 @@ from django.test.client import RequestFactory | ||||
| from django.urls import reverse | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| import structlog | ||||
|  | ||||
| import InvenTree.exceptions | ||||
| import InvenTree.helpers | ||||
| import InvenTree.models | ||||
| @@ -39,7 +40,7 @@ except OSError as err:  # pragma: no cover | ||||
|     sys.exit(1) | ||||
|  | ||||
|  | ||||
| logger = logging.getLogger('inventree') | ||||
| logger = structlog.getLogger('inventree') | ||||
|  | ||||
|  | ||||
| def dummy_print_request() -> HttpRequest: | ||||
| @@ -367,11 +368,12 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): | ||||
|  | ||||
|         return context | ||||
|  | ||||
|     def print(self, items: list, request=None, **kwargs) -> 'ReportOutput': | ||||
|     def print(self, items: list, request=None, output=None, **kwargs) -> 'ReportOutput': | ||||
|         """Print reports for a list of items against this template. | ||||
|  | ||||
|         Arguments: | ||||
|             items: A list of items to print reports for (model instance) | ||||
|             output: The ReportOutput object to use (if provided) | ||||
|             request: The request object (optional) | ||||
|  | ||||
|         Returns: | ||||
| @@ -390,6 +392,8 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): | ||||
|             - Render using background worker, provide progress updates to UI | ||||
|             - Run report generation in the background worker process | ||||
|         """ | ||||
|         logger.info("Printing %s reports against template '%s'", len(items), self.name) | ||||
|  | ||||
|         outputs = [] | ||||
|  | ||||
|         debug_mode = get_global_setting('REPORT_DEBUG_MODE', False) | ||||
| @@ -403,6 +407,23 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): | ||||
|  | ||||
|         report_plugins = registry.with_mixin('report') | ||||
|  | ||||
|         # If a ReportOutput object is not provided, create a new one | ||||
|         if not output: | ||||
|             output = ReportOutput.objects.create( | ||||
|                 template=self, | ||||
|                 items=len(items), | ||||
|                 user=request.user | ||||
|                 if request.user and request.user.is_authenticated | ||||
|                 else None, | ||||
|                 progress=0, | ||||
|                 complete=False, | ||||
|                 output=None, | ||||
|             ) | ||||
|  | ||||
|         if output.progress != 0: | ||||
|             output.progress = 0 | ||||
|             output.save() | ||||
|  | ||||
|         try: | ||||
|             for instance in items: | ||||
|                 context = self.get_context(instance, request) | ||||
| @@ -413,18 +434,18 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): | ||||
|                 # Render the report output | ||||
|                 try: | ||||
|                     if debug_mode: | ||||
|                         output = self.render_as_string(instance, request) | ||||
|                         report = self.render_as_string(instance, request) | ||||
|                     else: | ||||
|                         output = self.render(instance, request) | ||||
|                         report = self.render(instance, request) | ||||
|                 except TemplateDoesNotExist as e: | ||||
|                     t_name = str(e) or self.template | ||||
|                     raise ValidationError(f'Template file {t_name} does not exist') | ||||
|  | ||||
|                 outputs.append(output) | ||||
|                 outputs.append(report) | ||||
|  | ||||
|                 # Attach the generated report to the model instance (if required) | ||||
|                 if self.attach_to_model and not debug_mode: | ||||
|                     data = output.get_document().write_pdf() | ||||
|                     data = report.get_document().write_pdf() | ||||
|                     instance.create_attachment( | ||||
|                         attachment=ContentFile(data, report_name), | ||||
|                         comment=_(f'Report generated from template {self.name}'), | ||||
| @@ -436,11 +457,16 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): | ||||
|                 # Provide generated report to any interested plugins | ||||
|                 for plugin in report_plugins: | ||||
|                     try: | ||||
|                         plugin.report_callback(self, instance, output, request) | ||||
|                         plugin.report_callback(self, instance, report, request) | ||||
|                     except Exception: | ||||
|                         InvenTree.exceptions.log_error( | ||||
|                             f'plugins.{plugin.slug}.report_callback' | ||||
|                         ) | ||||
|  | ||||
|                 # Update the progress of the report generation | ||||
|                 output.progress += 1 | ||||
|                 output.save() | ||||
|  | ||||
|         except Exception as exc: | ||||
|             # Something went wrong during the report generation process | ||||
|             if get_global_setting('REPORT_LOG_ERRORS', backup_value=True): | ||||
| @@ -463,8 +489,8 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): | ||||
|             pages = [] | ||||
|  | ||||
|             try: | ||||
|                 for output in outputs: | ||||
|                     doc = output.get_document() | ||||
|                 for report in outputs: | ||||
|                     doc = report.get_document() | ||||
|                     for page in doc.pages: | ||||
|                         pages.append(page) | ||||
|  | ||||
| @@ -474,14 +500,9 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): | ||||
|                 raise ValidationError(f'Template file {t_name} does not exist') | ||||
|  | ||||
|         # Save the generated report to the database | ||||
|         output = ReportOutput.objects.create( | ||||
|             template=self, | ||||
|             items=len(items), | ||||
|             user=request.user if request.user.is_authenticated else None, | ||||
|             progress=100, | ||||
|             complete=True, | ||||
|             output=ContentFile(data, report_name), | ||||
|         ) | ||||
|         output.complete = True | ||||
|         output.output = ContentFile(data, report_name) | ||||
|         output.save() | ||||
|  | ||||
|         return output | ||||
|  | ||||
| @@ -560,13 +581,20 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): | ||||
|         return context | ||||
|  | ||||
|     def print( | ||||
|         self, items: list, plugin: InvenTreePlugin, options=None, request=None, **kwargs | ||||
|         self, | ||||
|         items: list, | ||||
|         plugin: InvenTreePlugin, | ||||
|         output=None, | ||||
|         options=None, | ||||
|         request=None, | ||||
|         **kwargs, | ||||
|     ) -> 'LabelOutput': | ||||
|         """Print labels for a list of items against this template. | ||||
|  | ||||
|         Arguments: | ||||
|             items: A list of items to print labels for (model instance) | ||||
|             plugin: The plugin to use for label rendering | ||||
|             output: The LabelOutput object to use (if provided) | ||||
|             options: Additional options for the label printing plugin (optional) | ||||
|             request: The request object (optional) | ||||
|  | ||||
| @@ -576,15 +604,23 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): | ||||
|         Raises: | ||||
|             ValidationError: If there is an error during label printing | ||||
|         """ | ||||
|         output = LabelOutput.objects.create( | ||||
|             template=self, | ||||
|             items=len(items), | ||||
|             plugin=plugin.slug, | ||||
|             user=request.user if request else None, | ||||
|             progress=0, | ||||
|             complete=False, | ||||
|         logger.info( | ||||
|             "Printing %s labels against template '%s' using plugin '%s'", | ||||
|             len(items), | ||||
|             plugin.slug, | ||||
|             self.name, | ||||
|         ) | ||||
|  | ||||
|         if not output: | ||||
|             output = LabelOutput.objects.create( | ||||
|                 template=self, | ||||
|                 items=len(items), | ||||
|                 plugin=plugin.slug, | ||||
|                 user=request.user if request else None, | ||||
|                 progress=0, | ||||
|                 complete=False, | ||||
|             ) | ||||
|  | ||||
|         if options is None: | ||||
|             options = {} | ||||
|  | ||||
| @@ -609,8 +645,7 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): | ||||
|             InvenTree.exceptions.log_error(f'plugins.{plugin.slug}.print_labels') | ||||
|             raise ValidationError([_('Error printing labels'), str(e)]) | ||||
|  | ||||
|         output.complete = True | ||||
|         output.save() | ||||
|         output.refresh_from_db() | ||||
|  | ||||
|         # Return the output object | ||||
|         return output | ||||
|   | ||||
| @@ -2,10 +2,15 @@ | ||||
|  | ||||
| from datetime import timedelta | ||||
|  | ||||
| import structlog | ||||
|  | ||||
| from InvenTree.exceptions import log_error | ||||
| from InvenTree.helpers import current_time | ||||
| from InvenTree.tasks import ScheduledTask, scheduled_task | ||||
| from report.models import LabelOutput, ReportOutput | ||||
|  | ||||
| logger = structlog.get_logger('inventree') | ||||
|  | ||||
|  | ||||
| @scheduled_task(ScheduledTask.DAILY) | ||||
| def cleanup_old_report_outputs(): | ||||
| @@ -15,3 +20,70 @@ def cleanup_old_report_outputs(): | ||||
|  | ||||
|     LabelOutput.objects.filter(created__lte=threshold).delete() | ||||
|     ReportOutput.objects.filter(created__lte=threshold).delete() | ||||
|  | ||||
|  | ||||
| def print_reports(template_id: int, item_ids: list[int], output_id: int, **kwargs): | ||||
|     """Print multiple reports against the provided template. | ||||
|  | ||||
|     Arguments: | ||||
|         template_id: The ID of the ReportTemplate to use | ||||
|         item_ids: List of item IDs to generate the report against | ||||
|         output_id: The ID of the ReportOutput to use (if provided) | ||||
|  | ||||
|     This function is intended to be called by the background worker, | ||||
|     and will continuously update the status of the ReportOutput object. | ||||
|     """ | ||||
|     from report.models import ReportOutput, ReportTemplate | ||||
|  | ||||
|     try: | ||||
|         template = ReportTemplate.objects.get(pk=template_id) | ||||
|         output = ReportOutput.objects.get(pk=output_id) | ||||
|     except Exception: | ||||
|         log_error('report.tasks.print_reports') | ||||
|         return | ||||
|  | ||||
|     # Fetch the items to be included in the report | ||||
|     model = template.get_model() | ||||
|     items = model.objects.filter(pk__in=item_ids) | ||||
|  | ||||
|     template.print(items, output=output) | ||||
|  | ||||
|  | ||||
| def print_labels( | ||||
|     template_id: int, item_ids: list[int], output_id: int, plugin_slug: str, **kwargs | ||||
| ): | ||||
|     """Print multiple labels against the provided template. | ||||
|  | ||||
|     Arguments: | ||||
|         template_id: The ID of the LabelTemplate to use | ||||
|         item_ids: List of item IDs to generate the labels against | ||||
|         output_id: The ID of the LabelOutput to use (if provided) | ||||
|         plugin_slug: The ID of the LabelPlugin to use (if provided) | ||||
|  | ||||
|     This function is intended to be called by the background worker, | ||||
|     and will continuously update the status of the LabelOutput object. | ||||
|     """ | ||||
|     from plugin.registry import registry | ||||
|     from report.models import LabelOutput, LabelTemplate | ||||
|  | ||||
|     try: | ||||
|         template = LabelTemplate.objects.get(pk=template_id) | ||||
|         output = LabelOutput.objects.get(pk=output_id) | ||||
|     except Exception: | ||||
|         log_error('report.tasks.print_labels') | ||||
|         return | ||||
|  | ||||
|     # Fetch the items to be included in the report | ||||
|     model = template.get_model() | ||||
|     items = model.objects.filter(pk__in=item_ids) | ||||
|  | ||||
|     plugin = registry.get_plugin(plugin_slug) | ||||
|  | ||||
|     if not plugin: | ||||
|         logger.warning("Label printing plugin '%s' not found", plugin_slug) | ||||
|         return | ||||
|  | ||||
|     # Extract optional arguments for label printing | ||||
|     options = kwargs.pop('options') or {} | ||||
|  | ||||
|     template.print(items, plugin, output=output, options=options) | ||||
|   | ||||
| @@ -1,10 +1,15 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { notifications } from '@mantine/notifications'; | ||||
| import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react'; | ||||
| import { notifications, showNotification } from '@mantine/notifications'; | ||||
| import { | ||||
|   IconCircleCheck, | ||||
|   IconPrinter, | ||||
|   IconReport, | ||||
|   IconTags | ||||
| } from '@tabler/icons-react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { useMemo, useState } from 'react'; | ||||
|  | ||||
| import { useEffect, useMemo, useState } from 'react'; | ||||
| import { api } from '../../App'; | ||||
| import { useApi } from '../../contexts/ApiContext'; | ||||
| import { ApiEndpoints } from '../../enums/ApiEndpoints'; | ||||
| import type { ModelType } from '../../enums/ModelType'; | ||||
| import { extractAvailableFields } from '../../functions/forms'; | ||||
| @@ -17,6 +22,94 @@ import { | ||||
| } from '../../states/SettingsState'; | ||||
| import type { ApiFormFieldSet } from '../forms/fields/ApiFormField'; | ||||
| import { ActionDropdown } from '../items/ActionDropdown'; | ||||
| import { ProgressBar } from '../items/ProgressBar'; | ||||
|  | ||||
| /** | ||||
|  * Hook to track the progress of a printing operation | ||||
|  */ | ||||
| function usePrintingProgress({ | ||||
|   title, | ||||
|   outputId, | ||||
|   endpoint | ||||
| }: { | ||||
|   title: string; | ||||
|   outputId?: number; | ||||
|   endpoint: ApiEndpoints; | ||||
| }) { | ||||
|   const api = useApi(); | ||||
|  | ||||
|   const [loading, setLoading] = useState<boolean>(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (!!outputId) { | ||||
|       setLoading(true); | ||||
|       showNotification({ | ||||
|         id: `printing-progress-${endpoint}-${outputId}`, | ||||
|         title: title, | ||||
|         loading: true, | ||||
|         autoClose: false, | ||||
|         withCloseButton: false, | ||||
|         message: <ProgressBar size='lg' value={0} progressLabel /> | ||||
|       }); | ||||
|     } else { | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }, [outputId, endpoint, title]); | ||||
|  | ||||
|   const progress = useQuery({ | ||||
|     enabled: !!outputId && loading, | ||||
|     refetchInterval: 750, | ||||
|     queryKey: ['printingProgress', endpoint, outputId], | ||||
|     queryFn: () => | ||||
|       api | ||||
|         .get(apiUrl(endpoint, outputId)) | ||||
|         .then((response) => { | ||||
|           const data = response?.data ?? {}; | ||||
|  | ||||
|           if (data.pk && data.pk == outputId) { | ||||
|             if (data.complete) { | ||||
|               setLoading(false); | ||||
|               notifications.hide(`printing-progress-${endpoint}-${outputId}`); | ||||
|               notifications.hide('print-success'); | ||||
|  | ||||
|               notifications.show({ | ||||
|                 id: 'print-success', | ||||
|                 title: t`Printing`, | ||||
|                 message: t`Printing completed successfully`, | ||||
|                 color: 'green', | ||||
|                 icon: <IconCircleCheck /> | ||||
|               }); | ||||
|  | ||||
|               if (data.output) { | ||||
|                 const url = generateUrl(data.output); | ||||
|                 window.open(url.toString(), '_blank'); | ||||
|               } | ||||
|             } else { | ||||
|               notifications.update({ | ||||
|                 id: `printing-progress-${endpoint}-${outputId}`, | ||||
|                 autoClose: false, | ||||
|                 withCloseButton: false, | ||||
|                 message: ( | ||||
|                   <ProgressBar | ||||
|                     size='lg' | ||||
|                     value={data.progress} | ||||
|                     maximum={data.items} | ||||
|                     progressLabel | ||||
|                   /> | ||||
|                 ) | ||||
|               }); | ||||
|             } | ||||
|           } | ||||
|  | ||||
|           return data; | ||||
|         }) | ||||
|         .catch(() => { | ||||
|           notifications.hide(`printing-progress-${endpoint}-${outputId}`); | ||||
|           setLoading(false); | ||||
|           return {}; | ||||
|         }) | ||||
|   }); | ||||
| } | ||||
|  | ||||
| export function PrintingActions({ | ||||
|   items, | ||||
| @@ -46,6 +139,21 @@ export function PrintingActions({ | ||||
|     return enableReports && globalSettings.isSet('REPORT_ENABLE'); | ||||
|   }, [enableReports, globalSettings]); | ||||
|  | ||||
|   const [labelId, setLabelId] = useState<number | undefined>(undefined); | ||||
|   const [reportId, setReportId] = useState<number | undefined>(undefined); | ||||
|  | ||||
|   const labelProgress = usePrintingProgress({ | ||||
|     title: t`Printing Labels`, | ||||
|     outputId: labelId, | ||||
|     endpoint: ApiEndpoints.label_output | ||||
|   }); | ||||
|  | ||||
|   const reportProgress = usePrintingProgress({ | ||||
|     title: t`Printing Reports`, | ||||
|     outputId: reportId, | ||||
|     endpoint: ApiEndpoints.report_output | ||||
|   }); | ||||
|  | ||||
|   // Fetch available printing fields via OPTIONS request | ||||
|   const printingFields = useQuery({ | ||||
|     enabled: labelPrintingEnabled, | ||||
| @@ -106,36 +214,22 @@ export function PrintingActions({ | ||||
|     url: apiUrl(ApiEndpoints.label_print), | ||||
|     title: t`Print Label`, | ||||
|     fields: labelFields, | ||||
|     timeout: (items.length + 1) * 5000, | ||||
|     timeout: 5000, | ||||
|     onClose: () => { | ||||
|       setPluginKey(''); | ||||
|     }, | ||||
|     submitText: t`Print`, | ||||
|     successMessage: t`Label printing completed successfully`, | ||||
|     successMessage: null, | ||||
|     onFormSuccess: (response: any) => { | ||||
|       setPluginKey(''); | ||||
|       if (!response.complete) { | ||||
|         // TODO: Periodically check for completion (requires server-side changes) | ||||
|         notifications.show({ | ||||
|           title: t`Error`, | ||||
|           message: t`The label could not be generated`, | ||||
|           color: 'red' | ||||
|         }); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (response.output) { | ||||
|         // An output file was generated | ||||
|         const url = generateUrl(response.output); | ||||
|         window.open(url.toString(), '_blank'); | ||||
|       } | ||||
|       setLabelId(response.pk); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   const reportModal = useCreateApiFormModal({ | ||||
|     title: t`Print Report`, | ||||
|     url: apiUrl(ApiEndpoints.report_print), | ||||
|     timeout: (items.length + 1) * 5000, | ||||
|     timeout: 5000, | ||||
|     fields: { | ||||
|       template: { | ||||
|         filters: { | ||||
| @@ -149,24 +243,10 @@ export function PrintingActions({ | ||||
|         value: items | ||||
|       } | ||||
|     }, | ||||
|     submitText: t`Generate`, | ||||
|     successMessage: t`Report printing completed successfully`, | ||||
|     submitText: t`Print`, | ||||
|     successMessage: null, | ||||
|     onFormSuccess: (response: any) => { | ||||
|       if (!response.complete) { | ||||
|         // TODO: Periodically check for completion (requires server-side changes) | ||||
|         notifications.show({ | ||||
|           title: t`Error`, | ||||
|           message: t`The report could not be generated`, | ||||
|           color: 'red' | ||||
|         }); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (response.output) { | ||||
|         // An output file was generated | ||||
|         const url = generateUrl(response.output); | ||||
|         window.open(url.toString(), '_blank'); | ||||
|       } | ||||
|       setReportId(response.pk); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   | ||||
| @@ -92,7 +92,7 @@ export interface ApiFormProps { | ||||
|   preFormWarning?: string; | ||||
|   preFormSuccess?: string; | ||||
|   postFormContent?: JSX.Element; | ||||
|   successMessage?: string; | ||||
|   successMessage?: string | null; | ||||
|   onFormSuccess?: (data: any) => void; | ||||
|   onFormError?: (response: any) => void; | ||||
|   processFormData?: (data: any) => any; | ||||
|   | ||||
| @@ -84,7 +84,10 @@ export function useCreateApiFormModal(props: ApiFormModalProps) { | ||||
|     () => ({ | ||||
|       ...props, | ||||
|       fetchInitialData: props.fetchInitialData ?? false, | ||||
|       successMessage: props.successMessage ?? t`Item Created`, | ||||
|       successMessage: | ||||
|         props.successMessage === null | ||||
|           ? null | ||||
|           : (props.successMessage ?? t`Item Created`), | ||||
|       method: 'POST' | ||||
|     }), | ||||
|     [props] | ||||
| @@ -101,7 +104,10 @@ export function useEditApiFormModal(props: ApiFormModalProps) { | ||||
|     () => ({ | ||||
|       ...props, | ||||
|       fetchInitialData: props.fetchInitialData ?? true, | ||||
|       successMessage: props.successMessage ?? t`Item Updated`, | ||||
|       successMessage: | ||||
|         props.successMessage === null | ||||
|           ? null | ||||
|           : (props.successMessage ?? t`Item Updated`), | ||||
|       method: 'PATCH' | ||||
|     }), | ||||
|     [props] | ||||
| @@ -120,7 +126,10 @@ export function useDeleteApiFormModal(props: ApiFormModalProps) { | ||||
|       method: 'DELETE', | ||||
|       submitText: t`Delete`, | ||||
|       submitColor: 'red', | ||||
|       successMessage: props.successMessage ?? t`Item Deleted`, | ||||
|       successMessage: | ||||
|         props.successMessage === null | ||||
|           ? null | ||||
|           : (props.successMessage ?? t`Item Deleted`), | ||||
|       preFormContent: props.preFormContent ?? ( | ||||
|         <Alert | ||||
|           color={'red'} | ||||
|   | ||||
| @@ -122,7 +122,7 @@ export default function SupplierPartDetail() { | ||||
|       } | ||||
|     ]; | ||||
|  | ||||
|     const tr: DetailsField[] = [ | ||||
|     const bl: DetailsField[] = [ | ||||
|       { | ||||
|         type: 'link', | ||||
|         name: 'supplier', | ||||
| @@ -165,7 +165,7 @@ export default function SupplierPartDetail() { | ||||
|       } | ||||
|     ]; | ||||
|  | ||||
|     const bl: DetailsField[] = [ | ||||
|     const br: DetailsField[] = [ | ||||
|       { | ||||
|         type: 'string', | ||||
|         name: 'packaging', | ||||
| @@ -183,7 +183,7 @@ export default function SupplierPartDetail() { | ||||
|       } | ||||
|     ]; | ||||
|  | ||||
|     const br: DetailsField[] = [ | ||||
|     const tr: DetailsField[] = [ | ||||
|       { | ||||
|         type: 'string', | ||||
|         name: 'in_stock', | ||||
| @@ -232,9 +232,9 @@ export default function SupplierPartDetail() { | ||||
|             <DetailsTable title={t`Part Details`} fields={tl} item={data} /> | ||||
|           </Grid.Col> | ||||
|         </Grid> | ||||
|         <DetailsTable title={t`Supplier`} fields={tr} item={data} /> | ||||
|         <DetailsTable title={t`Packaging`} fields={bl} item={data} /> | ||||
|         <DetailsTable title={t`Availability`} fields={br} item={data} /> | ||||
|         <DetailsTable title={t`Supplier`} fields={bl} item={data} /> | ||||
|         <DetailsTable title={t`Packaging`} fields={br} item={data} /> | ||||
|         <DetailsTable title={t`Availability`} fields={tr} item={data} /> | ||||
|       </ItemDetailsGrid> | ||||
|     ); | ||||
|   }, [supplierPart, instanceQuery.isFetching]); | ||||
|   | ||||
| @@ -212,6 +212,7 @@ export default function StockDetail() { | ||||
|         name: 'supplier_part', | ||||
|         label: t`Supplier Part`, | ||||
|         type: 'link', | ||||
|         model_field: 'SKU', | ||||
|         model: ModelType.supplierpart, | ||||
|         hidden: !stockitem.supplier_part | ||||
|       }, | ||||
|   | ||||
| @@ -41,9 +41,7 @@ test('Label Printing', async ({ page }) => { | ||||
|   await page.getByRole('button', { name: 'Print', exact: true }).isEnabled(); | ||||
|   await page.getByRole('button', { name: 'Print', exact: true }).click(); | ||||
|  | ||||
|   await page.locator('#form-success').waitFor(); | ||||
|   await page.getByText('Label printing completed').waitFor(); | ||||
|  | ||||
|   await page.getByText('Printing completed successfully').first().waitFor(); | ||||
|   await page.context().close(); | ||||
| }); | ||||
|  | ||||
| @@ -75,12 +73,10 @@ test('Report Printing', async ({ page }) => { | ||||
|   await page.waitForTimeout(100); | ||||
|  | ||||
|   // Submit the print form (should result in success) | ||||
|   await page.getByRole('button', { name: 'Generate', exact: true }).isEnabled(); | ||||
|   await page.getByRole('button', { name: 'Generate', exact: true }).click(); | ||||
|  | ||||
|   await page.locator('#form-success').waitFor(); | ||||
|   await page.getByText('Report printing completed').waitFor(); | ||||
|   await page.getByRole('button', { name: 'Print', exact: true }).isEnabled(); | ||||
|   await page.getByRole('button', { name: 'Print', exact: true }).click(); | ||||
|  | ||||
|   await page.getByText('Printing completed successfully').first().waitFor(); | ||||
|   await page.context().close(); | ||||
| }); | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user