From a453c9b28638f18a237ca4f89172c26211f1da95 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 17 Mar 2025 07:51:29 +1100 Subject: [PATCH] [refactor] Remove django-weasyprint entirely (#9316) * Remove django-weasyprint entirely * Handle null request * Bug fix --- docs/docs/extend/plugins/label.md | 1 - .../machine/machine_types/label_printer.py | 6 +- .../InvenTree/plugin/base/label/mixins.py | 14 +-- .../plugin/builtin/labels/inventree_label.py | 18 ++- src/backend/InvenTree/report/models.py | 113 +++++++----------- src/backend/requirements.in | 2 +- src/backend/requirements.txt | 14 +-- 7 files changed, 64 insertions(+), 104 deletions(-) diff --git a/docs/docs/extend/plugins/label.md b/docs/docs/extend/plugins/label.md index ff33d64e38..77b6927e78 100644 --- a/docs/docs/extend/plugins/label.md +++ b/docs/docs/extend/plugins/label.md @@ -148,7 +148,6 @@ class MyLabelPrinter(LabelPrintingMixin, InvenTreePlugin): Send the label to the printer kwargs: - pdf_file: The PDF file object of the rendered label (WeasyTemplateResponse object) pdf_data: Raw PDF data of the rendered label filename: The filename of this PDF label label_instance: The instance of the label model which triggered the print_label() method diff --git a/src/backend/InvenTree/machine/machine_types/label_printer.py b/src/backend/InvenTree/machine/machine_types/label_printer.py index 9ad74d3ba0..180a431c86 100644 --- a/src/backend/InvenTree/machine/machine_types/label_printer.py +++ b/src/backend/InvenTree/machine/machine_types/label_printer.py @@ -141,11 +141,7 @@ class LabelPrinterBaseDriver(BaseDriver): label: The LabelTemplate object to render item: The item to render the label with """ - return ( - self.render_to_pdf(label, item, **kwargs) - .get_document() # type: ignore - .write_pdf() - ) + return self.render_to_pdf(label, item, **kwargs) def render_to_html(self, label: LabelTemplate, item: models.Model, **kwargs) -> str: """Helper method to render a label to HTML format for a specific item. diff --git a/src/backend/InvenTree/plugin/base/label/mixins.py b/src/backend/InvenTree/plugin/base/label/mixins.py index 2542b8ad64..0561cd4a24 100644 --- a/src/backend/InvenTree/plugin/base/label/mixins.py +++ b/src/backend/InvenTree/plugin/base/label/mixins.py @@ -86,11 +86,7 @@ class LabelPrintingMixin: pdf_data = kwargs.get('pdf_data') if not pdf_data: - pdf_data = ( - self.render_to_pdf(label, instance, request, **kwargs) - .get_document() - .write_pdf() - ) + pdf_data = self.render_to_pdf(label, instance, request, **kwargs) pdf2image_kwargs = { 'dpi': kwargs.get('dpi', InvenTreeSetting.get_setting('LABEL_DPI', 300)), @@ -152,14 +148,12 @@ class LabelPrintingMixin: for item in items: context = label.get_context(item, request) filename = label.generate_filename(context) - pdf_file = self.render_to_pdf(label, item, request, **kwargs) - pdf_data = pdf_file.get_document().write_pdf() + pdf_data = self.render_to_pdf(label, item, request, **kwargs) png_file = self.render_to_png( label, item, request, pdf_data=pdf_data, **kwargs ) print_args = { - 'pdf_file': pdf_file, 'pdf_data': pdf_data, 'png_file': png_file, 'filename': filename, @@ -179,9 +173,6 @@ class LabelPrintingMixin: else: # Offload the print task to the background worker - # Exclude the 'pdf_file' object - cannot be pickled - print_args.pop('pdf_file', None) - # Exclude the 'context' object - cannot be pickled print_args.pop('context', None) @@ -216,7 +207,6 @@ class LabelPrintingMixin: """Print a single label (blocking). kwargs: - pdf_file: The PDF file object of the rendered label (WeasyTemplateResponse object) pdf_data: Raw PDF data of the rendered label filename: The filename of this PDF label label_instance: The instance of the label model which triggered the print_label() method diff --git a/src/backend/InvenTree/plugin/builtin/labels/inventree_label.py b/src/backend/InvenTree/plugin/builtin/labels/inventree_label.py index a0480ec89f..28158952a7 100644 --- a/src/backend/InvenTree/plugin/builtin/labels/inventree_label.py +++ b/src/backend/InvenTree/plugin/builtin/labels/inventree_label.py @@ -1,8 +1,12 @@ """Default label printing plugin (supports PDF generation).""" +import io + from django.core.files.base import ContentFile from django.utils.translation import gettext_lazy as _ +from pypdf import PdfWriter + from InvenTree.helpers import str2bool from plugin import InvenTreePlugin from plugin.mixins import LabelPrintingMixin, SettingsMixin @@ -50,7 +54,7 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin): output = self.render_to_html(label, instance, None, **kwargs) else: # Output is already provided - output = kwargs['pdf_file'] + output = kwargs.get('pdf_data') self.outputs.append(output) @@ -65,15 +69,17 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin): filename = 'labels.html' else: # Stitch together the PDF outputs - pages = [] + pdf_writer = PdfWriter() for output in self.outputs: - doc = output.get_document() + output_file = io.BytesIO(output) + pdf_writer.append(output_file) - for page in doc.pages: - pages.append(page) + pdf_file = io.BytesIO() + pdf_writer.write(pdf_file) + data = pdf_file.getvalue() + pdf_file.close() - data = self.outputs[0].get_document().copy(pages).write_pdf() filename = kwargs.get('filename', 'labels.pdf') return ContentFile(data, name=filename) diff --git a/src/backend/InvenTree/report/models.py b/src/backend/InvenTree/report/models.py index 9500d82ecf..7e97ac198a 100644 --- a/src/backend/InvenTree/report/models.py +++ b/src/backend/InvenTree/report/models.py @@ -1,25 +1,24 @@ """Report template model definitions.""" +import io import os import sys from django.conf import settings -from django.contrib.auth.models import AnonymousUser, User +from django.contrib.auth.models import 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.test.utils import override_settings from django.urls import reverse from django.utils.translation import gettext_lazy as _ import structlog +from pypdf import PdfWriter import InvenTree.exceptions import InvenTree.helpers @@ -33,10 +32,10 @@ from plugin import InvenTreePlugin from plugin.registry import registry try: - from django_weasyprint import WeasyTemplateResponseMixin + from weasyprint import HTML except OSError as err: # pragma: no cover print(f'OSError: {err}') - print("Unable to import 'django_weasyprint' module.") + print("Unable to import 'weasyprint' module.") print('You may require some further system packages to be installed.') sys.exit(1) @@ -44,32 +43,6 @@ except OSError as err: # pragma: no cover logger = structlog.getLogger('inventree') -MOCK_PRINT_HOST = 'localhost' - - -@override_settings(SITE_URL=MOCK_PRINT_HOST) -def dummy_print_request() -> HttpRequest: - """Generate a dummy HTTP request object. - - - This is required for internal print calls, as WeasyPrint *requires* a request object. - - Additionally, we have to mock the HOST header, as WeasyPrint requires a valid HOST URL. - """ - factory = RequestFactory() - request = factory.get('/', headers={'host': MOCK_PRINT_HOST}) - request.user = AnonymousUser() - return request - - -class WeasyprintReport(WeasyTemplateResponseMixin): - """Class for rendering a HTML template to a PDF.""" - - def __init__(self, request, template, **kwargs): - """Initialize the report mixin with some standard attributes.""" - self.request = request - self.template_name = template - self.pdf_filename = kwargs.get('filename', 'output.pdf') - - def rename_template(instance, filename): """Function to rename a report template once uploaded. @@ -200,33 +173,34 @@ class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel): return template_string.render(Context(context)) - def render_as_string(self, instance, request=None, **kwargs): + def render_as_string(self, instance, request=None, **kwargs) -> str: """Render the report to a HTML string. - Useful for debug mode (viewing generated code) + Arguments: + instance: The model instance to render against + request: A HTTPRequest object (optional) + + Returns: + str: HTML string """ context = self.get_context(instance, request, **kwargs) return render_to_string(self.template_name, context, request) - def render(self, instance, request=None, **kwargs): + def render(self, instance, request=None, **kwargs) -> bytes: """Render the template to a PDF file. - Uses django-weasyprint plugin to render HTML template against Weasyprint + Arguments: + instance: The model instance to render against + request: A HTTPRequest object (optional) + + Returns: + bytes: PDF data """ - context = self.get_context(instance, request) + html = self.render_as_string(instance, request, **kwargs) + pdf = HTML(string=html).write_pdf() - # Render HTML template to PDF - wp = WeasyprintReport( - request, - self.template_name, - base_url=get_base_url(request=request), - presentational_hints=True, - filename=self.generate_filename(context), - **kwargs, - ) - - return wp.render_to_response(context, **kwargs) + return pdf filename_pattern = models.CharField( default='output.pdf', @@ -407,10 +381,6 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): # 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') # If a ReportOutput object is not provided, create a new one @@ -419,7 +389,7 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): template=self, items=len(items), user=request.user - if request.user and request.user.is_authenticated + if request and request.user and request.user.is_authenticated else None, progress=0, complete=False, @@ -451,12 +421,11 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): # Attach the generated report to the model instance (if required) if self.attach_to_model and not debug_mode: - data = report.get_document().write_pdf() instance.create_attachment( - attachment=ContentFile(data, report_name), + attachment=ContentFile(report, report_name), comment=_(f'Report generated from template {self.name}'), upload_user=request.user - if request.user.is_authenticated + if request and request.user.is_authenticated else None, ) @@ -481,7 +450,7 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): raise ValidationError({ 'error': _('Error generating report'), 'detail': str(exc), - 'path': request.path, + 'path': request.path if request else None, }) if not report_name.endswith('.pdf'): @@ -492,18 +461,23 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): data = '\n'.join(outputs) report_name = report_name.replace('.pdf', '.html') else: - pages = [] + # Merge the outputs back together into a single PDF file + pdf_writer = PdfWriter() try: for report in outputs: - doc = report.get_document() - for page in doc.pages: - pages.append(page) + # Construct file object with raw PDF data + report_file = io.BytesIO(report) + pdf_writer.append(report_file) - 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') + # Generate raw output + pdf_file = io.BytesIO() + pdf_writer.write(pdf_file) + data = pdf_file.getvalue() + pdf_file.close() + except Exception: + InvenTree.exceptions.log_error('report.print') + data = None # Save the generated report to the database output.complete = True @@ -622,7 +596,9 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): template=self, items=len(items), plugin=plugin.slug, - user=request.user if request else None, + user=request.user + if request and request.user.is_authenticated + else None, progress=0, complete=False, ) @@ -630,11 +606,6 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): 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() diff --git a/src/backend/requirements.in b/src/backend/requirements.in index 9ede585b4f..ee3693fcc9 100644 --- a/src/backend/requirements.in +++ b/src/backend/requirements.in @@ -26,7 +26,6 @@ django-structlog # Structured logging django-stdimage # Advanced ImageField management django-taggit # Tagging support django-otp==1.3.0 # Two-factor authentication (legacy to ensure migrations) https://github.com/inventree/InvenTree/pull/6293 -django-weasyprint # django weasyprint integration djangorestframework<3.15 # DRF framework # FIXED 2024-06-26 see https://github.com/inventree/InvenTree/pull/7521 djangorestframework-simplejwt[crypto] # JWT authentication django-xforwardedfor-middleware # IP forwarding metadata @@ -40,6 +39,7 @@ pillow # Image manipulation pint # Unit conversion pip-licenses # License information for installed packages ppf.datamatrix # Data Matrix barcode generator +pypdf # PDF manipulation tools python-barcode[images] # Barcode generator python-dotenv # Environment variable management pyyaml>=6.0.1 # YAML parsing diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index dcdb3855d1..67c820d8b6 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -411,7 +411,6 @@ django==4.2.20 \ # django-stdimage # django-structlog # django-taggit - # django-weasyprint # django-xforwardedfor-middleware # djangorestframework # djangorestframework-simplejwt @@ -517,10 +516,6 @@ django-taggit==6.1.0 \ --hash=sha256:ab776264bbc76cb3d7e49e1bf9054962457831bd21c3a42db9138b41956e4cf0 \ --hash=sha256:c4d1199e6df34125dd36db5eb0efe545b254dec3980ce5dd80e6bab3e78757c3 # via -r src/backend/requirements.in -django-weasyprint==2.3.1 \ - --hash=sha256:09cc1c40c92db34bed80154be7c959fea03d6001dc46fd599f3fd464d6a6dc72 \ - --hash=sha256:cd35b8bd24b28128a17a2416d0e6f3e64cb727f25c53467150b4be16ccd01c19 - # via -r src/backend/requirements.in django-xforwardedfor-middleware==2.0 \ --hash=sha256:16fd1cb27f33a5541b6f3e0b43afb1b7334a76f27a1255b69e14ec5c440f0b24 # via -r src/backend/requirements.in @@ -1213,6 +1208,10 @@ pyjwt[crypto]==2.10.1 \ # via # django-allauth # djangorestframework-simplejwt +pypdf==5.4.0 \ + --hash=sha256:9af476a9dc30fcb137659b0dec747ea94aa954933c52cf02ee33e39a16fe9175 \ + --hash=sha256:db994ab47cadc81057ea1591b90e5b543e2b7ef2d0e31ef41a9bfe763c119dab + # via -r src/backend/requirements.in pyphen==0.17.2 \ --hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \ --hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3 @@ -1626,6 +1625,7 @@ typing-extensions==4.12.2 \ # opentelemetry-sdk # pint # py-moneyed + # pypdf # referencing # structlog tzdata==2025.1 \ @@ -1652,9 +1652,7 @@ wcwidth==0.2.13 \ weasyprint==64.1 \ --hash=sha256:28b02f2c6409bafce1b1220d9d76a7345875bd3bd08c4f6dfbf510bb92a94757 \ --hash=sha256:f7c88ea8ce0ce0c527cbb9c802689e035fae50016d7efc5dfdaba4b75abf68f4 - # via - # -r src/backend/requirements.in - # django-weasyprint + # via -r src/backend/requirements.in webencodings==0.5.1 \ --hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \ --hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923