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."""