mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 03:26:45 +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:
parent
821b311d73
commit
855afde4e5
@ -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.')
|
||||
|
@ -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:
|
||||
|
@ -291,6 +291,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
json={'name': 'morpheus', 'job': 'leader'},
|
||||
method='POST',
|
||||
endpoint_is_url=True,
|
||||
timeout=5000,
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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."""
|
||||
|
Loading…
x
Reference in New Issue
Block a user