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

View File

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

View File

@ -291,6 +291,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
json={'name': 'morpheus', 'job': 'leader'},
method='POST',
endpoint_is_url=True,
timeout=5000,
)
self.assertTrue(result)

View File

@ -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,31 +199,12 @@ 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,
)
try:
plugin.before_printing()
plugin.print_labels(
template,
output,
output = template.print(
items_to_print,
request,
printing_options=(plugin_serializer.data if plugin_serializer else {}),
plugin,
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()
@ -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

View File

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

View File

@ -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'<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
link = report_tags.internal_link(None, None)

View File

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