mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +00:00 
			
		
		
		
	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
This commit is contained in:
		@@ -124,6 +124,7 @@ class APICallMixin:
 | 
				
			|||||||
        headers: Optional[dict] = None,
 | 
					        headers: Optional[dict] = None,
 | 
				
			||||||
        simple_response: bool = True,
 | 
					        simple_response: bool = True,
 | 
				
			||||||
        endpoint_is_url: bool = False,
 | 
					        endpoint_is_url: bool = False,
 | 
				
			||||||
 | 
					        **kwargs,
 | 
				
			||||||
    ):
 | 
					    ):
 | 
				
			||||||
        """Do an API call.
 | 
					        """Do an API call.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -161,7 +162,7 @@ class APICallMixin:
 | 
				
			|||||||
            url = f'{self.api_url}/{endpoint}'
 | 
					            url = f'{self.api_url}/{endpoint}'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # build kwargs for call
 | 
					        # build kwargs for call
 | 
				
			||||||
        kwargs = {'url': url, 'headers': headers}
 | 
					        kwargs.update({'url': url, 'headers': headers})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if data and json:
 | 
					        if data and json:
 | 
				
			||||||
            raise ValueError('You can either pass `data` or `json` to this function.')
 | 
					            raise ValueError('You can either pass `data` or `json` to this function.')
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -46,7 +46,7 @@ class ReportMixin:
 | 
				
			|||||||
            context: The context dictionary to add to
 | 
					            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.
 | 
					        """Callback function called after a report is generated.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Arguments:
 | 
					        Arguments:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -291,6 +291,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
 | 
				
			|||||||
            json={'name': 'morpheus', 'job': 'leader'},
 | 
					            json={'name': 'morpheus', 'job': 'leader'},
 | 
				
			||||||
            method='POST',
 | 
					            method='POST',
 | 
				
			||||||
            endpoint_is_url=True,
 | 
					            endpoint_is_url=True,
 | 
				
			||||||
 | 
					            timeout=5000,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.assertTrue(result)
 | 
					        self.assertTrue(result)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,6 @@
 | 
				
			|||||||
"""API functionality for the 'report' app."""
 | 
					"""API functionality for the 'report' app."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.core.exceptions import ValidationError
 | 
					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.urls import include, path
 | 
				
			||||||
from django.utils.decorators import method_decorator
 | 
					from django.utils.decorators import method_decorator
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					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.generics import GenericAPIView
 | 
				
			||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import common.models
 | 
					 | 
				
			||||||
import InvenTree.exceptions
 | 
					import InvenTree.exceptions
 | 
				
			||||||
import InvenTree.helpers
 | 
					import InvenTree.helpers
 | 
				
			||||||
import InvenTree.permissions
 | 
					import InvenTree.permissions
 | 
				
			||||||
@@ -22,11 +19,9 @@ import report.helpers
 | 
				
			|||||||
import report.models
 | 
					import report.models
 | 
				
			||||||
import report.serializers
 | 
					import report.serializers
 | 
				
			||||||
from InvenTree.api import BulkDeleteMixin, MetadataView
 | 
					from InvenTree.api import BulkDeleteMixin, MetadataView
 | 
				
			||||||
from InvenTree.exceptions import log_error
 | 
					 | 
				
			||||||
from InvenTree.filters import InvenTreeSearchFilter
 | 
					from InvenTree.filters import InvenTreeSearchFilter
 | 
				
			||||||
from InvenTree.mixins import ListAPI, ListCreateAPI, RetrieveUpdateDestroyAPI
 | 
					from InvenTree.mixins import ListAPI, ListCreateAPI, RetrieveUpdateDestroyAPI
 | 
				
			||||||
from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin
 | 
					from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin
 | 
				
			||||||
from plugin.registry import registry
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TemplatePermissionMixin:
 | 
					class TemplatePermissionMixin:
 | 
				
			||||||
@@ -204,32 +199,13 @@ class LabelPrint(GenericAPIView):
 | 
				
			|||||||
        ):
 | 
					        ):
 | 
				
			||||||
            plugin_serializer.is_valid(raise_exception=True)
 | 
					            plugin_serializer.is_valid(raise_exception=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Create a new LabelOutput instance to print against
 | 
					        output = template.print(
 | 
				
			||||||
        output = report.models.LabelOutput.objects.create(
 | 
					            items_to_print,
 | 
				
			||||||
            template=template,
 | 
					            plugin,
 | 
				
			||||||
            items=len(items_to_print),
 | 
					            options=(plugin_serializer.data if plugin_serializer else {}),
 | 
				
			||||||
            plugin=plugin.slug,
 | 
					            request=request,
 | 
				
			||||||
            user=request.user,
 | 
					 | 
				
			||||||
            progress=0,
 | 
					 | 
				
			||||||
            complete=False,
 | 
					 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        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()
 | 
					        output.refresh_from_db()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return Response(
 | 
					        return Response(
 | 
				
			||||||
@@ -280,124 +256,7 @@ class ReportPrint(GenericAPIView):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def print(self, template, items_to_print, request):
 | 
					    def print(self, template, items_to_print, request):
 | 
				
			||||||
        """Print this report template against a number of provided items."""
 | 
					        """Print this report template against a number of provided items."""
 | 
				
			||||||
        outputs = []
 | 
					        output = template.print(items_to_print, request)
 | 
				
			||||||
 | 
					 | 
				
			||||||
        # 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),
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return Response(
 | 
					        return Response(
 | 
				
			||||||
            report.serializers.ReportOutputSerializer(output).data, status=201
 | 
					            report.serializers.ReportOutputSerializer(output).data, status=201
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,13 +5,17 @@ import os
 | 
				
			|||||||
import sys
 | 
					import sys
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					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.exceptions import ValidationError
 | 
				
			||||||
 | 
					from django.core.files.base import ContentFile
 | 
				
			||||||
from django.core.files.storage import default_storage
 | 
					from django.core.files.storage import default_storage
 | 
				
			||||||
from django.core.validators import FileExtensionValidator, MinValueValidator
 | 
					from django.core.validators import FileExtensionValidator, MinValueValidator
 | 
				
			||||||
from django.db import models
 | 
					from django.db import models
 | 
				
			||||||
 | 
					from django.http import HttpRequest
 | 
				
			||||||
from django.template import Context, Template
 | 
					from django.template import Context, Template
 | 
				
			||||||
 | 
					from django.template.exceptions import TemplateDoesNotExist
 | 
				
			||||||
from django.template.loader import render_to_string
 | 
					from django.template.loader import render_to_string
 | 
				
			||||||
 | 
					from django.test.client import RequestFactory
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -23,6 +27,7 @@ import report.validators
 | 
				
			|||||||
from common.settings import get_global_setting
 | 
					from common.settings import get_global_setting
 | 
				
			||||||
from InvenTree.helpers_model import get_base_url
 | 
					from InvenTree.helpers_model import get_base_url
 | 
				
			||||||
from InvenTree.models import MetadataMixin
 | 
					from InvenTree.models import MetadataMixin
 | 
				
			||||||
 | 
					from plugin import InvenTreePlugin
 | 
				
			||||||
from plugin.registry import registry
 | 
					from plugin.registry import registry
 | 
				
			||||||
 | 
					
 | 
				
			||||||
try:
 | 
					try:
 | 
				
			||||||
@@ -37,6 +42,17 @@ except OSError as err:  # pragma: no cover
 | 
				
			|||||||
logger = logging.getLogger('inventree')
 | 
					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 WeasyprintReport(WeasyTemplateResponseMixin):
 | 
				
			||||||
    """Class for rendering a HTML template to a PDF."""
 | 
					    """Class for rendering a HTML template to a PDF."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -67,7 +83,7 @@ def rename_template(instance, filename):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TemplateUploadMixin:
 | 
					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 determining the upload path for a template
 | 
				
			||||||
    - Provides generic method for checking for duplicate filenames
 | 
					    - Provides generic method for checking for duplicate filenames
 | 
				
			||||||
@@ -197,7 +213,7 @@ class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
 | 
				
			|||||||
        wp = WeasyprintReport(
 | 
					        wp = WeasyprintReport(
 | 
				
			||||||
            request,
 | 
					            request,
 | 
				
			||||||
            self.template_name,
 | 
					            self.template_name,
 | 
				
			||||||
            base_url=request.build_absolute_uri('/'),
 | 
					            base_url=get_base_url(request=request),
 | 
				
			||||||
            presentational_hints=True,
 | 
					            presentational_hints=True,
 | 
				
			||||||
            filename=self.generate_filename(context),
 | 
					            filename=self.generate_filename(context),
 | 
				
			||||||
            **kwargs,
 | 
					            **kwargs,
 | 
				
			||||||
@@ -351,6 +367,124 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        return context
 | 
					        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 LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
 | 
				
			||||||
    """Class representing the LabelTemplate database model."""
 | 
					    """Class representing the LabelTemplate database model."""
 | 
				
			||||||
@@ -425,6 +559,64 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        return context
 | 
					        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):
 | 
					class TemplateOutput(models.Model):
 | 
				
			||||||
    """Base class representing a generated file from a template.
 | 
					    """Base class representing a generated file from a template.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -170,9 +170,15 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
 | 
				
			|||||||
        obj = Part.objects.create(name='test', description='test')
 | 
					        obj = Part.objects.create(name='test', description='test')
 | 
				
			||||||
        self.assertEqual(report_tags.internal_link(obj, 'test123'), 'test123')
 | 
					        self.assertEqual(report_tags.internal_link(obj, 'test123'), 'test123')
 | 
				
			||||||
        link = report_tags.internal_link(obj.get_absolute_url(), 'test')
 | 
					        link = report_tags.internal_link(obj.get_absolute_url(), 'test')
 | 
				
			||||||
        self.assertEqual(
 | 
					
 | 
				
			||||||
            link, f'<a href="http://localhost:8000/platform/part/{obj.pk}">test</a>'
 | 
					        # 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'<a href="http://localhost:8000/platform/part/{obj.pk}">test</a>',
 | 
				
			||||||
 | 
					            f'<a href="/platform/part/{obj.pk}">test</a>',
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertIn(link, options)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Test with an invalid object
 | 
					        # Test with an invalid object
 | 
				
			||||||
        link = report_tags.internal_link(None, None)
 | 
					        link = report_tags.internal_link(None, None)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -277,6 +277,74 @@ class ReportTest(InvenTreeAPITestCase):
 | 
				
			|||||||
        template.refresh_from_db()
 | 
					        template.refresh_from_db()
 | 
				
			||||||
        self.assertEqual(template.description, 'An updated description')
 | 
					        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:
 | 
					class PrintTestMixins:
 | 
				
			||||||
    """Mixin that enables e2e printing tests."""
 | 
					    """Mixin that enables e2e printing tests."""
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user