From 855afde4e5dd85a30b8f45afebab6e8eb1a34f59 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 1 Feb 2025 17:08:33 +1100 Subject: [PATCH] Report print update (#9002) * Refactor label printing - Move "print" method to template - Allows for internal calls to printing (e.g. plugins) * Generate dummy request - Required to trick WeasyPrint * Refactor reportprinting * Add timeout for unit test * More unit tests * Tweak unit test * Updated comment --- .../plugin/base/integration/APICallMixin.py | 3 +- .../plugin/base/integration/ReportMixin.py | 2 +- .../plugin/base/integration/test_mixins.py | 1 + src/backend/InvenTree/report/api.py | 153 +------------- src/backend/InvenTree/report/models.py | 198 +++++++++++++++++- src/backend/InvenTree/report/test_tags.py | 12 +- src/backend/InvenTree/report/tests.py | 68 ++++++ 7 files changed, 282 insertions(+), 155 deletions(-) diff --git a/src/backend/InvenTree/plugin/base/integration/APICallMixin.py b/src/backend/InvenTree/plugin/base/integration/APICallMixin.py index 3fa95824a2..5aa04c2d96 100644 --- a/src/backend/InvenTree/plugin/base/integration/APICallMixin.py +++ b/src/backend/InvenTree/plugin/base/integration/APICallMixin.py @@ -124,6 +124,7 @@ class APICallMixin: headers: Optional[dict] = None, simple_response: bool = True, endpoint_is_url: bool = False, + **kwargs, ): """Do an API call. @@ -161,7 +162,7 @@ class APICallMixin: url = f'{self.api_url}/{endpoint}' # build kwargs for call - kwargs = {'url': url, 'headers': headers} + kwargs.update({'url': url, 'headers': headers}) if data and json: raise ValueError('You can either pass `data` or `json` to this function.') diff --git a/src/backend/InvenTree/plugin/base/integration/ReportMixin.py b/src/backend/InvenTree/plugin/base/integration/ReportMixin.py index 738b74d3c5..781a80b9e9 100644 --- a/src/backend/InvenTree/plugin/base/integration/ReportMixin.py +++ b/src/backend/InvenTree/plugin/base/integration/ReportMixin.py @@ -46,7 +46,7 @@ class ReportMixin: context: The context dictionary to add to """ - def report_callback(self, template, instance, report, request): + def report_callback(self, template, instance, report, request, **kwargs): """Callback function called after a report is generated. Arguments: diff --git a/src/backend/InvenTree/plugin/base/integration/test_mixins.py b/src/backend/InvenTree/plugin/base/integration/test_mixins.py index 4c47879221..14413e4692 100644 --- a/src/backend/InvenTree/plugin/base/integration/test_mixins.py +++ b/src/backend/InvenTree/plugin/base/integration/test_mixins.py @@ -291,6 +291,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): json={'name': 'morpheus', 'job': 'leader'}, method='POST', endpoint_is_url=True, + timeout=5000, ) self.assertTrue(result) diff --git a/src/backend/InvenTree/report/api.py b/src/backend/InvenTree/report/api.py index ac2ce9cfdc..a5adbbc49d 100644 --- a/src/backend/InvenTree/report/api.py +++ b/src/backend/InvenTree/report/api.py @@ -1,8 +1,6 @@ """API functionality for the 'report' app.""" from django.core.exceptions import ValidationError -from django.core.files.base import ContentFile -from django.template.exceptions import TemplateDoesNotExist from django.urls import include, path from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ @@ -14,7 +12,6 @@ from rest_framework import permissions from rest_framework.generics import GenericAPIView from rest_framework.response import Response -import common.models import InvenTree.exceptions import InvenTree.helpers import InvenTree.permissions @@ -22,11 +19,9 @@ import report.helpers import report.models import report.serializers from InvenTree.api import BulkDeleteMixin, MetadataView -from InvenTree.exceptions import log_error from InvenTree.filters import InvenTreeSearchFilter from InvenTree.mixins import ListAPI, ListCreateAPI, RetrieveUpdateDestroyAPI from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin -from plugin.registry import registry class TemplatePermissionMixin: @@ -204,32 +199,13 @@ class LabelPrint(GenericAPIView): ): plugin_serializer.is_valid(raise_exception=True) - # Create a new LabelOutput instance to print against - output = report.models.LabelOutput.objects.create( - template=template, - items=len(items_to_print), - plugin=plugin.slug, - user=request.user, - progress=0, - complete=False, + output = template.print( + items_to_print, + plugin, + options=(plugin_serializer.data if plugin_serializer else {}), + request=request, ) - try: - plugin.before_printing() - plugin.print_labels( - template, - output, - items_to_print, - request, - printing_options=(plugin_serializer.data if plugin_serializer else {}), - ) - plugin.after_printing() - except ValidationError as e: - raise (e) - except Exception as e: - InvenTree.exceptions.log_error(f'plugins.{plugin.slug}.print_labels') - raise ValidationError([_('Error printing label'), str(e)]) - output.refresh_from_db() return Response( @@ -280,124 +256,7 @@ class ReportPrint(GenericAPIView): def print(self, template, items_to_print, request): """Print this report template against a number of provided items.""" - outputs = [] - - # In debug mode, generate single HTML output, rather than PDF - debug_mode = common.models.InvenTreeSetting.get_setting( - 'REPORT_DEBUG_MODE', cache=False - ) - - # Start with a default report name - report_name = 'report.pdf' - - try: - # Merge one or more PDF files into a single download - for instance in items_to_print: - context = template.get_context(instance, request) - report_name = template.generate_filename(context) - - output = template.render(instance, request) - - if template.attach_to_model: - # Attach the generated report to the model instance - data = output.get_document().write_pdf() - instance.create_attachment( - attachment=ContentFile(data, report_name), - comment=_('Report saved at time of printing'), - upload_user=request.user, - ) - - # Provide generated report to any interested plugins - for plugin in registry.with_mixin('report'): - try: - plugin.report_callback(self, instance, output, request) - except Exception: - InvenTree.exceptions.log_error( - f'plugins.{plugin.slug}.report_callback' - ) - - try: - if debug_mode: - outputs.append(template.render_as_string(instance, request)) - else: - outputs.append(template.render(instance, request)) - except TemplateDoesNotExist as e: - template = str(e) - if not template: - template = template.template - - return Response( - { - 'error': _( - f"Template file '{template}' is missing or does not exist" - ) - }, - status=400, - ) - - if not report_name.endswith('.pdf'): - report_name += '.pdf' - - if debug_mode: - """Concatenate all rendered templates into a single HTML string, and return the string as a HTML response.""" - - data = '\n'.join(outputs) - report_name = report_name.replace('.pdf', '.html') - else: - """Concatenate all rendered pages into a single PDF object, and return the resulting document!""" - - pages = [] - - try: - for output in outputs: - doc = output.get_document() - for page in doc.pages: - pages.append(page) - - data = outputs[0].get_document().copy(pages).write_pdf() - - except TemplateDoesNotExist as e: - template = str(e) - - if not template: - template = template.template - - return Response( - { - 'error': _( - f"Template file '{template}' is missing or does not exist" - ) - }, - status=400, - ) - - except Exception as exc: - # Log the exception to the database - if InvenTree.helpers.str2bool( - common.models.InvenTreeSetting.get_setting( - 'REPORT_LOG_ERRORS', cache=False - ) - ): - log_error(request.path) - - # Re-throw the exception to the client as a DRF exception - raise ValidationError({ - 'error': 'Report printing failed', - 'detail': str(exc), - 'path': request.path, - }) - - # Generate a report output object - # TODO: This should be moved to a separate function - # TODO: Allow background printing of reports, with progress reporting - output = report.models.ReportOutput.objects.create( - template=template, - items=len(items_to_print), - user=request.user, - progress=100, - complete=True, - output=ContentFile(data, report_name), - ) + output = template.print(items_to_print, request) return Response( report.serializers.ReportOutputSerializer(output).data, status=201 diff --git a/src/backend/InvenTree/report/models.py b/src/backend/InvenTree/report/models.py index 0364887e3b..872d2e68a6 100644 --- a/src/backend/InvenTree/report/models.py +++ b/src/backend/InvenTree/report/models.py @@ -5,13 +5,17 @@ import os import sys from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth.models import AnonymousUser, User from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.core.validators import FileExtensionValidator, MinValueValidator from django.db import models +from django.http import HttpRequest from django.template import Context, Template +from django.template.exceptions import TemplateDoesNotExist from django.template.loader import render_to_string +from django.test.client import RequestFactory from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -23,6 +27,7 @@ import report.validators from common.settings import get_global_setting from InvenTree.helpers_model import get_base_url from InvenTree.models import MetadataMixin +from plugin import InvenTreePlugin from plugin.registry import registry try: @@ -37,6 +42,17 @@ except OSError as err: # pragma: no cover logger = logging.getLogger('inventree') +def dummy_print_request() -> HttpRequest: + """Generate a dummy HTTP request object. + + This is required for internal print calls, as WeasyPrint *requires* a request object. + """ + factory = RequestFactory() + request = factory.get('/') + request.user = AnonymousUser() + return request + + class WeasyprintReport(WeasyTemplateResponseMixin): """Class for rendering a HTML template to a PDF.""" @@ -67,7 +83,7 @@ def rename_template(instance, filename): class TemplateUploadMixin: - """Mixin class for providing template pathing functions. + """Mixin class for providing template path management functions. - Provides generic method for determining the upload path for a template - Provides generic method for checking for duplicate filenames @@ -197,7 +213,7 @@ class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel): wp = WeasyprintReport( request, self.template_name, - base_url=request.build_absolute_uri('/'), + base_url=get_base_url(request=request), presentational_hints=True, filename=self.generate_filename(context), **kwargs, @@ -351,6 +367,124 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): return context + def print(self, items: list, request=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) + request: The request object (optional) + + Returns: + output: The ReportOutput object representing the generated report(s) + + Raises: + ValidationError: If there is an error during report printing + + Notes: + Currently, all items are rendered separately into PDF files, + and then combined into a single PDF file. + + Further work is required to allow the following extended features: + - Render a single PDF file with the collated items (optional per template) + - Render a raw file (do not convert to PDF) - allows for other file types + - Render using background worker, provide progress updates to UI + - Run report generation in the background worker process + """ + outputs = [] + + debug_mode = get_global_setting('REPORT_DEBUG_MODE', False) + + # Start with a default report name + report_name = None + + if request is None: + # Generate a dummy request object + request = dummy_print_request() + + report_plugins = registry.with_mixin('report') + + try: + 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: + output = self.render_as_string(instance, request) + else: + output = 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) + + # 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() + instance.create_attachment( + attachment=ContentFile(data, report_name), + comment=_(f'Report generated from template {self.name}'), + upload_user=request.user + if request.user.is_authenticated + else None, + ) + + # Provide generated report to any interested plugins + for plugin in report_plugins: + try: + plugin.report_callback(self, instance, output, request) + except Exception: + InvenTree.exceptions.log_error( + f'plugins.{plugin.slug}.report_callback' + ) + except Exception as exc: + # Something went wrong during the report generation process + if get_global_setting('REPORT_LOG_ERRORS', backup_value=True): + InvenTree.exceptions.log_error('report.print') + + raise ValidationError({ + 'error': _('Error generating report'), + 'detail': str(exc), + 'path': request.path, + }) + + if not report_name.endswith('.pdf'): + report_name += '.pdf' + + # Combine all the generated reports into a single PDF file + if debug_mode: + data = '\n'.join(outputs) + report_name = report_name.replace('.pdf', '.html') + else: + pages = [] + + try: + for output in outputs: + doc = output.get_document() + for page in doc.pages: + pages.append(page) + + data = outputs[0].get_document().copy(pages).write_pdf() + except TemplateDoesNotExist as exc: + t_name = str(exc) or self.template + 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), + ) + + return output + class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): """Class representing the LabelTemplate database model.""" @@ -425,6 +559,64 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): return context + def print( + self, items: list, plugin: InvenTreePlugin, 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 + options: Additional options for the label printing plugin (optional) + request: The request object (optional) + + Returns: + output: The LabelOutput object representing the generated label(s) + + 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, + ) + + if options is None: + options = {} + + if request is None: + # If the request object is not provided, we need to create a dummy one + # Otherwise, WeasyPrint throws an error + request = dummy_print_request() + + try: + if hasattr(plugin, 'before_printing'): + plugin.before_printing() + + plugin.print_labels( + self, output, items, request, printing_options=options + ) + + if hasattr(plugin, 'after_printing'): + plugin.after_printing() + except ValidationError as e: + output.delete() + raise e + except Exception as e: + output.delete() + InvenTree.exceptions.log_error(f'plugins.{plugin.slug}.print_labels') + raise ValidationError([_('Error printing labels'), str(e)]) + + output.complete = True + output.save() + + # Return the output object + return output + class TemplateOutput(models.Model): """Base class representing a generated file from a template. diff --git a/src/backend/InvenTree/report/test_tags.py b/src/backend/InvenTree/report/test_tags.py index bf6265a354..7638888846 100644 --- a/src/backend/InvenTree/report/test_tags.py +++ b/src/backend/InvenTree/report/test_tags.py @@ -170,9 +170,15 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): obj = Part.objects.create(name='test', description='test') self.assertEqual(report_tags.internal_link(obj, 'test123'), 'test123') link = report_tags.internal_link(obj.get_absolute_url(), 'test') - self.assertEqual( - link, f'test' - ) + + # Test might return one of two results, depending on test env + # If INVENTREE_SITE_URL is not set in the CI environment, the link will be relative + options = [ + f'test', + f'test', + ] + + self.assertIn(link, options) # Test with an invalid object link = report_tags.internal_link(None, None) diff --git a/src/backend/InvenTree/report/tests.py b/src/backend/InvenTree/report/tests.py index e72b0bd890..ee737fcc60 100644 --- a/src/backend/InvenTree/report/tests.py +++ b/src/backend/InvenTree/report/tests.py @@ -277,6 +277,74 @@ class ReportTest(InvenTreeAPITestCase): template.refresh_from_db() self.assertEqual(template.description, 'An updated description') + def test_print(self): + """Test that we can print a report manually.""" + # Find a suitable report template + template = ReportTemplate.objects.filter( + enabled=True, model_type='stockitem' + ).first() + + # Gather some items + items = StockItem.objects.all()[0:5] + + output = template.print(items) + + self.assertTrue(output.complete) + self.assertEqual(output.items, 5) + self.assertIsNotNone(output.output) + self.assertTrue(output.output.name.endswith('.pdf')) + + +class LabelTest(InvenTreeAPITestCase): + """Unit tests for label templates.""" + + fixtures = [ + 'category', + 'part', + 'company', + 'location', + 'test_templates', + 'supplier_part', + 'stock', + 'stock_tests', + 'bom', + 'build', + 'order', + 'return_order', + 'sales_order', + ] + + superuser = True + + def setUp(self): + """Ensure cache is cleared as part of test setup.""" + cache.clear() + + apps.get_app_config('report').create_default_labels() + + return super().setUp() + + def test_print(self): + """Test manual printing of label templates.""" + # Find a suitable label template + template = LabelTemplate.objects.filter(enabled=True, model_type='part').first() + + # Gather some items + parts = Part.objects.all()[0:10] + + # Find the label plugin (render to pdf) + plugin = registry.get_plugin('inventreelabel') + + self.assertIsNotNone(template) + self.assertIsNotNone(plugin) + + output = template.print(items=parts, plugin=plugin) + + self.assertTrue(output.complete) + self.assertEqual(output.items, 10) + self.assertIsNotNone(output.output) + self.assertTrue(output.output.name.endswith('.pdf')) + class PrintTestMixins: """Mixin that enables e2e printing tests."""