2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +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:
Oliver 2025-02-01 17:08:33 +11:00 committed by GitHub
parent 821b311d73
commit 855afde4e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 282 additions and 155 deletions

View File

@ -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.')

View File

@ -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:

View File

@ -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)

View File

@ -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,31 +199,12 @@ 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(
template=template,
items=len(items_to_print),
plugin=plugin.slug,
user=request.user,
progress=0,
complete=False,
)
try:
plugin.before_printing()
plugin.print_labels(
template,
output,
items_to_print, items_to_print,
request, plugin,
printing_options=(plugin_serializer.data if plugin_serializer else {}), options=(plugin_serializer.data if plugin_serializer else {}),
request=request,
) )
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()
@ -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

View File

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

View File

@ -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)

View File

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